@gramatr/mcp 0.13.41 → 0.13.43
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/dist/bin/gramatr-mcp.d.ts.map +1 -1
- package/dist/bin/gramatr-mcp.js +188 -179
- package/dist/bin/gramatr-mcp.js.map +1 -1
- package/dist/bin/login.d.ts.map +1 -1
- package/dist/bin/login.js +238 -164
- package/dist/bin/login.js.map +1 -1
- package/dist/bin/setup.d.ts.map +1 -1
- package/dist/bin/setup.js +32 -14
- package/dist/bin/setup.js.map +1 -1
- package/package.json +1 -1
- package/scripts/postinstall.mjs +1 -1
package/dist/bin/login.js
CHANGED
|
@@ -15,32 +15,33 @@
|
|
|
15
15
|
* Token is stored in ~/.gramatr.json under the "token" key.
|
|
16
16
|
* The local gramatr MCP runtime reads this on every proxied request.
|
|
17
17
|
*/
|
|
18
|
-
import { createHash, randomBytes } from
|
|
19
|
-
import { readFileSync, writeFileSync } from
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
18
|
+
import { createHash, randomBytes } from "crypto";
|
|
19
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
20
|
+
import { createServer } from "http";
|
|
21
|
+
import { join } from "path";
|
|
22
22
|
// ── Config ──
|
|
23
|
-
const HOME = process.env.HOME || process.env.USERPROFILE ||
|
|
24
|
-
const CONFIG_PATH = join(HOME,
|
|
25
|
-
const
|
|
26
|
-
const DEFAULT_SERVER = process.env.GRAMATR_URL ||
|
|
23
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || "";
|
|
24
|
+
const CONFIG_PATH = join(HOME, ".gramatr.json");
|
|
25
|
+
const _GRAMATR_DIR = process.env.GRAMATR_DIR || join(HOME, ".gramatr");
|
|
26
|
+
const DEFAULT_SERVER = process.env.GRAMATR_URL || "https://api.gramatr.com/mcp";
|
|
27
27
|
// Strip /mcp suffix to get base URL
|
|
28
|
-
const SERVER_BASE = DEFAULT_SERVER.replace(/\/mcp\/?$/,
|
|
29
|
-
const DASHBOARD_BASE = process.env.GMTR_DASHBOARD_URL ||
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
const SERVER_BASE = DEFAULT_SERVER.replace(/\/mcp\/?$/, "");
|
|
29
|
+
const DASHBOARD_BASE = process.env.GMTR_DASHBOARD_URL ||
|
|
30
|
+
(() => {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(SERVER_BASE);
|
|
33
|
+
if (url.hostname.startsWith("api.")) {
|
|
34
|
+
url.hostname = `app.${url.hostname.slice(4)}`;
|
|
35
|
+
}
|
|
36
|
+
url.pathname = "";
|
|
37
|
+
url.search = "";
|
|
38
|
+
url.hash = "";
|
|
39
|
+
return url.toString().replace(/\/$/, "");
|
|
34
40
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
return 'https://app.gramatr.com';
|
|
42
|
-
}
|
|
43
|
-
})();
|
|
41
|
+
catch {
|
|
42
|
+
return "https://app.gramatr.com";
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
44
45
|
// CALLBACK_PORT is now dynamically allocated per login (random localhost port).
|
|
45
46
|
// The server's DCR endpoint accepts arbitrary localhost redirect_uris for
|
|
46
47
|
// public CLIs (token_endpoint_auth_method=none). See loginBrowser() below.
|
|
@@ -167,7 +168,7 @@ function htmlPage(title, body) {
|
|
|
167
168
|
</body></html>`;
|
|
168
169
|
}
|
|
169
170
|
function successPage() {
|
|
170
|
-
return htmlPage(
|
|
171
|
+
return htmlPage("Authenticated", `
|
|
171
172
|
<div class="status-icon">${CHECK_SVG}</div>
|
|
172
173
|
<h2>You're signed in</h2>
|
|
173
174
|
<p>Your token is saved on this machine. You can close this tab and return to your terminal.</p>
|
|
@@ -175,7 +176,7 @@ function successPage() {
|
|
|
175
176
|
`);
|
|
176
177
|
}
|
|
177
178
|
function errorPage(title, detail) {
|
|
178
|
-
return htmlPage(
|
|
179
|
+
return htmlPage("Error", `
|
|
179
180
|
<div class="status-icon">${X_SVG}</div>
|
|
180
181
|
<h2>${title}</h2>
|
|
181
182
|
<p>${detail}</p>
|
|
@@ -187,7 +188,7 @@ export function isHeadless(forceFlag = false) {
|
|
|
187
188
|
// Explicit override — user passed --headless, OR env GRAMATR_LOGIN_HEADLESS=1
|
|
188
189
|
if (forceFlag)
|
|
189
190
|
return true;
|
|
190
|
-
if (process.env.GRAMATR_LOGIN_HEADLESS ===
|
|
191
|
+
if (process.env.GRAMATR_LOGIN_HEADLESS === "1")
|
|
191
192
|
return true;
|
|
192
193
|
// SSH session — always go headless. Even on macOS (which has a local
|
|
193
194
|
// display), `open` would launch Safari on the Mac's *physical* screen,
|
|
@@ -199,21 +200,21 @@ export function isHeadless(forceFlag = false) {
|
|
|
199
200
|
if (process.env.CI || process.env.DOCKER)
|
|
200
201
|
return true;
|
|
201
202
|
// Linux without display
|
|
202
|
-
if (process.platform ===
|
|
203
|
+
if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY)
|
|
203
204
|
return true;
|
|
204
205
|
return false;
|
|
205
206
|
}
|
|
206
207
|
// ── Helpers ──
|
|
207
208
|
export function readConfig() {
|
|
208
209
|
try {
|
|
209
|
-
return JSON.parse(readFileSync(CONFIG_PATH,
|
|
210
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
210
211
|
}
|
|
211
212
|
catch {
|
|
212
213
|
return {};
|
|
213
214
|
}
|
|
214
215
|
}
|
|
215
216
|
export function writeConfig(config) {
|
|
216
|
-
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) +
|
|
217
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n");
|
|
217
218
|
}
|
|
218
219
|
/**
|
|
219
220
|
* Kill any running gramatr stdio server processes so they respawn with
|
|
@@ -227,15 +228,15 @@ export function writeConfig(config) {
|
|
|
227
228
|
*/
|
|
228
229
|
export function killStaleMcpServers() {
|
|
229
230
|
try {
|
|
230
|
-
const { execSync } = require(
|
|
231
|
-
if (process.platform ===
|
|
232
|
-
execSync(
|
|
231
|
+
const { execSync } = require("child_process");
|
|
232
|
+
if (process.platform === "win32") {
|
|
233
|
+
execSync("taskkill /F /IM gramatr.exe /T 2>nul", { stdio: "ignore" });
|
|
233
234
|
}
|
|
234
235
|
else {
|
|
235
236
|
// pkill -TERM -f matches against the full command line; the $ anchor ensures
|
|
236
237
|
// we only kill processes whose argv[1] ends in 'gramatr' (the stdio server),
|
|
237
238
|
// not subcommands like 'gramatr login' which have trailing arguments.
|
|
238
|
-
execSync(`pkill -TERM -f 'gramatr$' 2>/dev/null; true`, { stdio:
|
|
239
|
+
execSync(`pkill -TERM -f 'gramatr$' 2>/dev/null; true`, { stdio: "ignore" });
|
|
239
240
|
}
|
|
240
241
|
}
|
|
241
242
|
catch {
|
|
@@ -244,7 +245,7 @@ export function killStaleMcpServers() {
|
|
|
244
245
|
}
|
|
245
246
|
export async function readJsonRecord(response) {
|
|
246
247
|
const payload = await response.json().catch(() => ({}));
|
|
247
|
-
if (!payload || typeof payload !==
|
|
248
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
|
248
249
|
return {};
|
|
249
250
|
return payload;
|
|
250
251
|
}
|
|
@@ -252,7 +253,7 @@ export async function checkServerHealth() {
|
|
|
252
253
|
try {
|
|
253
254
|
const res = await fetch(`${SERVER_BASE}/health`, { signal: AbortSignal.timeout(5000) });
|
|
254
255
|
if (res.ok) {
|
|
255
|
-
const data = await res.json();
|
|
256
|
+
const data = (await res.json());
|
|
256
257
|
return { ok: true, version: data.version };
|
|
257
258
|
}
|
|
258
259
|
return { ok: false, error: `HTTP ${res.status}` };
|
|
@@ -264,35 +265,37 @@ export async function checkServerHealth() {
|
|
|
264
265
|
export async function testToken(token) {
|
|
265
266
|
try {
|
|
266
267
|
const res = await fetch(`${SERVER_BASE}/mcp`, {
|
|
267
|
-
method:
|
|
268
|
+
method: "POST",
|
|
268
269
|
headers: {
|
|
269
|
-
|
|
270
|
-
Accept:
|
|
270
|
+
"Content-Type": "application/json",
|
|
271
|
+
Accept: "application/json, text/event-stream",
|
|
271
272
|
Authorization: `Bearer ${token}`,
|
|
272
273
|
},
|
|
273
274
|
body: JSON.stringify({
|
|
274
|
-
jsonrpc:
|
|
275
|
+
jsonrpc: "2.0",
|
|
275
276
|
id: 1,
|
|
276
|
-
method:
|
|
277
|
-
params: { name:
|
|
277
|
+
method: "tools/call",
|
|
278
|
+
params: { name: "aggregate_stats", arguments: {} },
|
|
278
279
|
}),
|
|
279
280
|
signal: AbortSignal.timeout(10000),
|
|
280
281
|
});
|
|
281
282
|
const text = await res.text();
|
|
282
283
|
// Check for auth errors
|
|
283
|
-
if (text.includes(
|
|
284
|
-
|
|
284
|
+
if (text.includes("JWT token is required") ||
|
|
285
|
+
text.includes("signature validation failed") ||
|
|
286
|
+
text.includes("Unauthorized")) {
|
|
287
|
+
return { valid: false, error: "Token rejected by server" };
|
|
285
288
|
}
|
|
286
289
|
// Check for successful response
|
|
287
|
-
for (const line of text.split(
|
|
288
|
-
if (line.startsWith(
|
|
290
|
+
for (const line of text.split("\n")) {
|
|
291
|
+
if (line.startsWith("data: ")) {
|
|
289
292
|
try {
|
|
290
293
|
const d = JSON.parse(line.slice(6));
|
|
291
294
|
if (d?.result?.isError) {
|
|
292
|
-
return { valid: false, error: d.result.content?.[0]?.text ||
|
|
295
|
+
return { valid: false, error: d.result.content?.[0]?.text || "Unknown error" };
|
|
293
296
|
}
|
|
294
297
|
if (d?.result?.content?.[0]?.text) {
|
|
295
|
-
return { valid: true, user:
|
|
298
|
+
return { valid: true, user: "authenticated" };
|
|
296
299
|
}
|
|
297
300
|
}
|
|
298
301
|
catch {
|
|
@@ -300,7 +303,7 @@ export async function testToken(token) {
|
|
|
300
303
|
}
|
|
301
304
|
}
|
|
302
305
|
}
|
|
303
|
-
return { valid: false, error:
|
|
306
|
+
return { valid: false, error: "Unexpected response" };
|
|
304
307
|
}
|
|
305
308
|
catch (e) {
|
|
306
309
|
return { valid: false, error: e.message };
|
|
@@ -308,35 +311,37 @@ export async function testToken(token) {
|
|
|
308
311
|
}
|
|
309
312
|
export async function startDeviceAuthorization() {
|
|
310
313
|
const res = await fetch(`${SERVER_BASE}/device/start`, {
|
|
311
|
-
method:
|
|
312
|
-
headers: {
|
|
313
|
-
body: JSON.stringify({ client_name:
|
|
314
|
+
method: "POST",
|
|
315
|
+
headers: { "Content-Type": "application/json" },
|
|
316
|
+
body: JSON.stringify({ client_name: "gramatr-mcp-login" }),
|
|
314
317
|
signal: AbortSignal.timeout(10000),
|
|
315
318
|
});
|
|
316
319
|
const payload = await readJsonRecord(res);
|
|
317
320
|
if (!res.ok) {
|
|
318
321
|
throw new Error(payload.error_description || payload.error || `HTTP ${res.status}`);
|
|
319
322
|
}
|
|
320
|
-
if (typeof payload.device_code !==
|
|
321
|
-
typeof payload.user_code !==
|
|
322
|
-
typeof payload.verification_uri !==
|
|
323
|
-
typeof payload.expires_in !==
|
|
324
|
-
throw new Error(
|
|
323
|
+
if (typeof payload.device_code !== "string" ||
|
|
324
|
+
typeof payload.user_code !== "string" ||
|
|
325
|
+
typeof payload.verification_uri !== "string" ||
|
|
326
|
+
typeof payload.expires_in !== "number") {
|
|
327
|
+
throw new Error("Device authorization response missing required fields");
|
|
325
328
|
}
|
|
326
329
|
return {
|
|
327
330
|
device_code: payload.device_code,
|
|
328
331
|
user_code: payload.user_code,
|
|
329
332
|
verification_uri: payload.verification_uri,
|
|
330
|
-
verification_uri_complete: typeof payload.verification_uri_complete ===
|
|
333
|
+
verification_uri_complete: typeof payload.verification_uri_complete === "string"
|
|
334
|
+
? payload.verification_uri_complete
|
|
335
|
+
: undefined,
|
|
331
336
|
expires_in: payload.expires_in,
|
|
332
|
-
interval: typeof payload.interval ===
|
|
337
|
+
interval: typeof payload.interval === "number" ? payload.interval : 5,
|
|
333
338
|
};
|
|
334
339
|
}
|
|
335
340
|
export async function pollDeviceAuthorization(deviceCode) {
|
|
336
341
|
while (true) {
|
|
337
342
|
const res = await fetch(`${SERVER_BASE}/device/token`, {
|
|
338
|
-
method:
|
|
339
|
-
headers: {
|
|
343
|
+
method: "POST",
|
|
344
|
+
headers: { "Content-Type": "application/json" },
|
|
340
345
|
body: JSON.stringify({ device_code: deviceCode }),
|
|
341
346
|
signal: AbortSignal.timeout(10000),
|
|
342
347
|
});
|
|
@@ -344,7 +349,7 @@ export async function pollDeviceAuthorization(deviceCode) {
|
|
|
344
349
|
if (res.ok && payload.access_token) {
|
|
345
350
|
return payload.access_token;
|
|
346
351
|
}
|
|
347
|
-
if ((res.status === 428 || res.status === 400) && payload.error ===
|
|
352
|
+
if ((res.status === 428 || res.status === 400) && payload.error === "authorization_pending") {
|
|
348
353
|
const waitSeconds = Math.max(1, Number(payload.interval) || 5);
|
|
349
354
|
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
|
|
350
355
|
continue;
|
|
@@ -354,33 +359,33 @@ export async function pollDeviceAuthorization(deviceCode) {
|
|
|
354
359
|
}
|
|
355
360
|
// ── Commands ──
|
|
356
361
|
export async function showStatus() {
|
|
357
|
-
console.log(
|
|
362
|
+
console.log("\n gramatr authentication status\n");
|
|
358
363
|
const config = readConfig();
|
|
359
364
|
const token = config.token;
|
|
360
365
|
console.log(` Server: ${SERVER_BASE}`);
|
|
361
366
|
const health = await checkServerHealth();
|
|
362
367
|
if (health.ok) {
|
|
363
|
-
console.log(` Health: ✓ healthy (v${health.version ||
|
|
368
|
+
console.log(` Health: ✓ healthy (v${health.version || "unknown"})`);
|
|
364
369
|
}
|
|
365
370
|
else {
|
|
366
371
|
console.log(` Health: ✗ ${health.error}`);
|
|
367
372
|
}
|
|
368
373
|
if (!token) {
|
|
369
|
-
console.log(
|
|
370
|
-
console.log(
|
|
374
|
+
console.log(" Token: ✗ not configured");
|
|
375
|
+
console.log("\n Run: gramatr login to authenticate\n");
|
|
371
376
|
return;
|
|
372
377
|
}
|
|
373
378
|
const prefix = token.substring(0, 15);
|
|
374
379
|
console.log(` Token: ${prefix}...`);
|
|
375
380
|
const result = await testToken(token);
|
|
376
381
|
if (result.valid) {
|
|
377
|
-
console.log(
|
|
382
|
+
console.log(" Auth: ✓ token is valid");
|
|
378
383
|
}
|
|
379
384
|
else {
|
|
380
385
|
console.log(` Auth: ✗ ${result.error}`);
|
|
381
|
-
console.log(
|
|
386
|
+
console.log("\n Run: gramatr login to re-authenticate\n");
|
|
382
387
|
}
|
|
383
|
-
console.log(
|
|
388
|
+
console.log("");
|
|
384
389
|
}
|
|
385
390
|
export async function logout() {
|
|
386
391
|
const config = readConfig();
|
|
@@ -389,7 +394,7 @@ export async function logout() {
|
|
|
389
394
|
delete config.token_expires;
|
|
390
395
|
delete config.authenticated_at;
|
|
391
396
|
writeConfig(config);
|
|
392
|
-
console.log(
|
|
397
|
+
console.log("\n ✓ Logged out. Token removed from ~/.gramatr.json\n");
|
|
393
398
|
}
|
|
394
399
|
async function fetchUserIdentity(token) {
|
|
395
400
|
try {
|
|
@@ -399,11 +404,29 @@ async function fetchUserIdentity(token) {
|
|
|
399
404
|
});
|
|
400
405
|
if (!res.ok)
|
|
401
406
|
return {};
|
|
402
|
-
const data = await res.json();
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
+
const data = (await res.json());
|
|
408
|
+
const user_id = typeof data.user_id === "string" ? data.user_id : undefined;
|
|
409
|
+
const email = typeof data.email === "string" ? data.email : undefined;
|
|
410
|
+
// Attempt to fetch display name from profile API (404 expected for new users)
|
|
411
|
+
let name;
|
|
412
|
+
if (user_id) {
|
|
413
|
+
try {
|
|
414
|
+
const profileRes = await fetch(`${SERVER_BASE}/api/v1/profile/${user_id}`, {
|
|
415
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
416
|
+
signal: AbortSignal.timeout(5000),
|
|
417
|
+
});
|
|
418
|
+
if (profileRes.ok) {
|
|
419
|
+
const profile = (await profileRes.json());
|
|
420
|
+
if (typeof profile.name === "string" && profile.name.trim()) {
|
|
421
|
+
name = profile.name.trim();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
// non-fatal — profile fetch failure does not block login
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return { user_id, email, name };
|
|
407
430
|
}
|
|
408
431
|
catch {
|
|
409
432
|
return {};
|
|
@@ -412,7 +435,7 @@ async function fetchUserIdentity(token) {
|
|
|
412
435
|
async function clearReauthFlag(token) {
|
|
413
436
|
try {
|
|
414
437
|
await fetch(`${SERVER_BASE}/api/v1/auth/reauth-flag`, {
|
|
415
|
-
method:
|
|
438
|
+
method: "DELETE",
|
|
416
439
|
headers: { Authorization: `Bearer ${token}` },
|
|
417
440
|
signal: AbortSignal.timeout(5000),
|
|
418
441
|
});
|
|
@@ -431,10 +454,10 @@ async function promptUserNameIfAbsent() {
|
|
|
431
454
|
const config = readConfig();
|
|
432
455
|
if (config.user?.name)
|
|
433
456
|
return;
|
|
434
|
-
const { createInterface } = await import(
|
|
457
|
+
const { createInterface } = await import("readline/promises");
|
|
435
458
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
436
459
|
try {
|
|
437
|
-
const answer = await rl.question(
|
|
460
|
+
const answer = await rl.question(" What should grāmatr call you? [Enter to skip] ");
|
|
438
461
|
const name = answer.trim();
|
|
439
462
|
if (name) {
|
|
440
463
|
const updated = readConfig();
|
|
@@ -445,64 +468,100 @@ async function promptUserNameIfAbsent() {
|
|
|
445
468
|
rl.close();
|
|
446
469
|
}
|
|
447
470
|
}
|
|
471
|
+
/**
|
|
472
|
+
* Prompt the user to enable auto-compact if not already configured.
|
|
473
|
+
* Only runs when stdin and stderr are TTYs (interactive terminal).
|
|
474
|
+
*/
|
|
475
|
+
async function promptAutoCompactIfAbsent() {
|
|
476
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY)
|
|
477
|
+
return;
|
|
478
|
+
const config = readConfig();
|
|
479
|
+
if (config.auto_compact !== undefined)
|
|
480
|
+
return;
|
|
481
|
+
process.stderr.write(" grāmatr session continuity: saves full context every 15 turns — tasks, git state,\n");
|
|
482
|
+
process.stderr.write(" recent work. Restores automatically after /clear so nothing is lost.\n");
|
|
483
|
+
const { createInterface } = await import("readline/promises");
|
|
484
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
485
|
+
try {
|
|
486
|
+
const answer = await rl.question(" Enable auto-compact? Automatically condenses context when it gets long. [Y/n] ");
|
|
487
|
+
const normalized = answer.trim().toLowerCase();
|
|
488
|
+
const enable = normalized !== "n" && normalized !== "no";
|
|
489
|
+
const updated = readConfig();
|
|
490
|
+
writeConfig({ ...updated, auto_compact: { auto: enable } });
|
|
491
|
+
}
|
|
492
|
+
finally {
|
|
493
|
+
rl.close();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Run all post-login onboarding prompts in order.
|
|
498
|
+
* Called at every login path that completes successfully.
|
|
499
|
+
*/
|
|
500
|
+
async function runPostLoginOnboarding() {
|
|
501
|
+
await promptUserNameIfAbsent();
|
|
502
|
+
await promptAutoCompactIfAbsent();
|
|
503
|
+
}
|
|
448
504
|
export async function loginWithToken(token) {
|
|
449
|
-
console.log(
|
|
505
|
+
console.log("\n Testing token...");
|
|
450
506
|
const result = await testToken(token);
|
|
451
507
|
if (result.valid) {
|
|
452
508
|
const config = readConfig();
|
|
453
509
|
config.token = token;
|
|
454
|
-
config.token_type = /^[a-z]+_(?:sk|pk)_/i.test(token) ?
|
|
510
|
+
config.token_type = /^[a-z]+_(?:sk|pk)_/i.test(token) ? "api_key" : "oauth";
|
|
455
511
|
config.authenticated_at = new Date().toISOString();
|
|
456
512
|
const [identity] = await Promise.all([fetchUserIdentity(token), clearReauthFlag(token)]);
|
|
457
513
|
if (identity.user_id)
|
|
458
514
|
config.user_id = identity.user_id;
|
|
459
515
|
if (identity.email)
|
|
460
516
|
config.email = identity.email;
|
|
517
|
+
if (identity.name && !config.user?.name) {
|
|
518
|
+
config.user = { ...(config.user ?? {}), name: identity.name };
|
|
519
|
+
}
|
|
461
520
|
writeConfig(config);
|
|
462
521
|
killStaleMcpServers();
|
|
463
|
-
await
|
|
464
|
-
console.log(
|
|
465
|
-
console.log(
|
|
522
|
+
await runPostLoginOnboarding();
|
|
523
|
+
console.log(" ✓ Token valid. Saved to ~/.gramatr.json");
|
|
524
|
+
console.log(" gramatr intelligence is now active.\n");
|
|
466
525
|
}
|
|
467
526
|
else {
|
|
468
527
|
console.log(` ✗ Token rejected: ${result.error}`);
|
|
469
|
-
console.log(
|
|
528
|
+
console.log(" Token was NOT saved.\n");
|
|
470
529
|
process.exit(1);
|
|
471
530
|
}
|
|
472
531
|
}
|
|
473
532
|
export async function loginBrowser(opts = {}) {
|
|
474
|
-
console.log(
|
|
533
|
+
console.log("\n gramatr login\n");
|
|
475
534
|
console.log(` Server: ${SERVER_BASE}`);
|
|
476
535
|
console.log(` Dashboard: ${DASHBOARD_BASE}`);
|
|
477
536
|
// Check server health first
|
|
478
537
|
const health = await checkServerHealth();
|
|
479
538
|
if (!health.ok) {
|
|
480
539
|
console.log(` ✗ Server unreachable: ${health.error}`);
|
|
481
|
-
console.log(
|
|
540
|
+
console.log(" Cannot authenticate. Is the server running?\n");
|
|
482
541
|
process.exit(1);
|
|
483
542
|
return;
|
|
484
543
|
}
|
|
485
|
-
console.log(` Health: ✓ v${health.version ||
|
|
486
|
-
console.log(
|
|
544
|
+
console.log(` Health: ✓ v${health.version || "unknown"}`);
|
|
545
|
+
console.log("");
|
|
487
546
|
// Headless environments use device auth (no local server needed).
|
|
488
547
|
// --headless flag or GRAMATR_LOGIN_HEADLESS=1 forces this path even on
|
|
489
548
|
// desktop — escape hatch when the browser flow is broken or the user
|
|
490
549
|
// prefers the device flow's out-of-band UX.
|
|
491
550
|
if (isHeadless(opts.forceHeadless)) {
|
|
492
|
-
console.log(
|
|
551
|
+
console.log(" Headless environment detected. Starting device login...\n");
|
|
493
552
|
try {
|
|
494
553
|
const device = await startDeviceAuthorization();
|
|
495
554
|
console.log(` Code: ${device.user_code}`);
|
|
496
555
|
console.log(` Open: ${device.verification_uri_complete || device.verification_uri}`);
|
|
497
|
-
console.log(
|
|
498
|
-
console.log(
|
|
556
|
+
console.log(" Sign in with Google or GitHub, approve the device, then return here.\n");
|
|
557
|
+
console.log(" Waiting for authorization...");
|
|
499
558
|
// v0.3.63 hotfix: must clear the timeout after the race resolves,
|
|
500
559
|
// otherwise the orphan setTimeout keeps the Node event loop alive
|
|
501
560
|
// until expires_in elapses (typically 600s). Symptom: success path
|
|
502
561
|
// prints "Authenticated successfully" and then hangs until Ctrl+C.
|
|
503
562
|
let timeoutHandle;
|
|
504
563
|
const timeoutPromise = new Promise((_, reject) => {
|
|
505
|
-
timeoutHandle = setTimeout(() => reject(new Error(
|
|
564
|
+
timeoutHandle = setTimeout(() => reject(new Error("Device login timed out")), device.expires_in * 1000);
|
|
506
565
|
});
|
|
507
566
|
let accessToken;
|
|
508
567
|
try {
|
|
@@ -517,28 +576,34 @@ export async function loginBrowser(opts = {}) {
|
|
|
517
576
|
}
|
|
518
577
|
const config = readConfig();
|
|
519
578
|
config.token = accessToken;
|
|
520
|
-
config.token_type =
|
|
579
|
+
config.token_type = "oauth";
|
|
521
580
|
config.authenticated_at = new Date().toISOString();
|
|
522
581
|
config.server_url = SERVER_BASE;
|
|
523
582
|
config.dashboard_url = DASHBOARD_BASE;
|
|
524
583
|
config.token_expires_at = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
|
|
525
|
-
const [deviceIdentity] = await Promise.all([
|
|
584
|
+
const [deviceIdentity] = await Promise.all([
|
|
585
|
+
fetchUserIdentity(accessToken),
|
|
586
|
+
clearReauthFlag(accessToken),
|
|
587
|
+
]);
|
|
526
588
|
if (deviceIdentity.user_id)
|
|
527
589
|
config.user_id = deviceIdentity.user_id;
|
|
528
590
|
if (deviceIdentity.email)
|
|
529
591
|
config.email = deviceIdentity.email;
|
|
592
|
+
if (deviceIdentity.name && !config.user?.name) {
|
|
593
|
+
config.user = { ...(config.user ?? {}), name: deviceIdentity.name };
|
|
594
|
+
}
|
|
530
595
|
writeConfig(config);
|
|
531
596
|
killStaleMcpServers();
|
|
532
|
-
await
|
|
533
|
-
console.log(
|
|
534
|
-
console.log(
|
|
535
|
-
console.log(
|
|
536
|
-
console.log(
|
|
597
|
+
await runPostLoginOnboarding();
|
|
598
|
+
console.log("");
|
|
599
|
+
console.log(" ✓ Authenticated successfully");
|
|
600
|
+
console.log(" Token saved to ~/.gramatr.json");
|
|
601
|
+
console.log(" gramatr intelligence is now active.\n");
|
|
537
602
|
return;
|
|
538
603
|
}
|
|
539
604
|
catch (e) {
|
|
540
605
|
console.log(` ✗ Device login failed: ${e.message}`);
|
|
541
|
-
console.log(
|
|
606
|
+
console.log(" Run `gramatr login` when you're ready to authenticate.\n");
|
|
542
607
|
process.exit(1);
|
|
543
608
|
return;
|
|
544
609
|
}
|
|
@@ -559,8 +624,8 @@ export async function loginBrowser(opts = {}) {
|
|
|
559
624
|
// 1. Bind a localhost callback server (random port — server's DCR allows it)
|
|
560
625
|
const callbackServer = createServer();
|
|
561
626
|
await new Promise((resolve, reject) => {
|
|
562
|
-
callbackServer.once(
|
|
563
|
-
callbackServer.listen(0,
|
|
627
|
+
callbackServer.once("error", reject);
|
|
628
|
+
callbackServer.listen(0, "127.0.0.1", () => resolve());
|
|
564
629
|
});
|
|
565
630
|
const port = callbackServer.address().port;
|
|
566
631
|
const redirectUri = `http://localhost:${port}/callback`;
|
|
@@ -571,14 +636,14 @@ export async function loginBrowser(opts = {}) {
|
|
|
571
636
|
let clientId;
|
|
572
637
|
try {
|
|
573
638
|
const regRes = await fetch(`${SERVER_BASE}/register`, {
|
|
574
|
-
method:
|
|
575
|
-
headers: {
|
|
639
|
+
method: "POST",
|
|
640
|
+
headers: { "Content-Type": "application/json" },
|
|
576
641
|
body: JSON.stringify({
|
|
577
|
-
client_name:
|
|
642
|
+
client_name: "gramatr-mcp-login",
|
|
578
643
|
redirect_uris: [redirectUri],
|
|
579
|
-
grant_types: [
|
|
580
|
-
response_types: [
|
|
581
|
-
token_endpoint_auth_method:
|
|
644
|
+
grant_types: ["authorization_code"],
|
|
645
|
+
response_types: ["code"],
|
|
646
|
+
token_endpoint_auth_method: "none",
|
|
582
647
|
}),
|
|
583
648
|
signal: AbortSignal.timeout(10000),
|
|
584
649
|
});
|
|
@@ -597,9 +662,9 @@ export async function loginBrowser(opts = {}) {
|
|
|
597
662
|
return;
|
|
598
663
|
}
|
|
599
664
|
// 3. PKCE: generate verifier + S256 challenge
|
|
600
|
-
const codeVerifier = randomBytes(32).toString(
|
|
601
|
-
const codeChallenge = createHash(
|
|
602
|
-
const state = randomBytes(16).toString(
|
|
665
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
666
|
+
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
667
|
+
const state = randomBytes(16).toString("base64url");
|
|
603
668
|
// 4. Run the callback server, waiting for /callback?code=...&state=...
|
|
604
669
|
//
|
|
605
670
|
// v0.6.5: capture the timeout handle and clearTimeout() in finally — same
|
|
@@ -608,16 +673,16 @@ export async function loginBrowser(opts = {}) {
|
|
|
608
673
|
// alive past success and the process hangs until Ctrl+C.
|
|
609
674
|
let codeTimeoutHandle;
|
|
610
675
|
const codePromise = new Promise((resolve, reject) => {
|
|
611
|
-
callbackServer.on(
|
|
612
|
-
const url = new URL(req.url ||
|
|
613
|
-
if (url.pathname !==
|
|
676
|
+
callbackServer.on("request", (req, res) => {
|
|
677
|
+
const url = new URL(req.url || "/", redirectUri);
|
|
678
|
+
if (url.pathname !== "/callback") {
|
|
614
679
|
res.writeHead(404);
|
|
615
|
-
res.end(
|
|
680
|
+
res.end("Not found");
|
|
616
681
|
return;
|
|
617
682
|
}
|
|
618
|
-
const code = url.searchParams.get(
|
|
619
|
-
const returnedState = url.searchParams.get(
|
|
620
|
-
const error = url.searchParams.get(
|
|
683
|
+
const code = url.searchParams.get("code");
|
|
684
|
+
const returnedState = url.searchParams.get("state");
|
|
685
|
+
const error = url.searchParams.get("error");
|
|
621
686
|
// v0.6.6: `server.close()` alone does not terminate keep-alive
|
|
622
687
|
// sockets — the browser holds the connection open and the Node
|
|
623
688
|
// event loop never exits, so the CLI prints "Authenticated" and
|
|
@@ -626,50 +691,50 @@ export async function loginBrowser(opts = {}) {
|
|
|
626
691
|
// lingering sockets after we respond.
|
|
627
692
|
const shutdown = () => {
|
|
628
693
|
callbackServer.close();
|
|
629
|
-
if (typeof callbackServer.closeAllConnections ===
|
|
694
|
+
if (typeof callbackServer.closeAllConnections === "function") {
|
|
630
695
|
callbackServer.closeAllConnections();
|
|
631
696
|
}
|
|
632
697
|
};
|
|
633
698
|
if (error) {
|
|
634
|
-
res.writeHead(200, {
|
|
635
|
-
res.end(errorPage(
|
|
699
|
+
res.writeHead(200, { "Content-Type": "text/html", Connection: "close" });
|
|
700
|
+
res.end(errorPage("Authentication Failed", error));
|
|
636
701
|
shutdown();
|
|
637
702
|
reject(new Error(`OAuth error: ${error}`));
|
|
638
703
|
return;
|
|
639
704
|
}
|
|
640
705
|
if (!code || returnedState !== state) {
|
|
641
|
-
res.writeHead(400, {
|
|
642
|
-
res.end(errorPage(
|
|
706
|
+
res.writeHead(400, { "Content-Type": "text/html", Connection: "close" });
|
|
707
|
+
res.end(errorPage("Invalid Callback", "Missing code or state mismatch. Please try again."));
|
|
643
708
|
shutdown();
|
|
644
|
-
reject(new Error(
|
|
709
|
+
reject(new Error("Invalid callback"));
|
|
645
710
|
return;
|
|
646
711
|
}
|
|
647
|
-
res.writeHead(200, {
|
|
712
|
+
res.writeHead(200, { "Content-Type": "text/html", Connection: "close" });
|
|
648
713
|
res.end(successPage());
|
|
649
714
|
shutdown();
|
|
650
715
|
resolve(code);
|
|
651
716
|
});
|
|
652
717
|
codeTimeoutHandle = setTimeout(() => {
|
|
653
718
|
callbackServer.close();
|
|
654
|
-
reject(new Error(
|
|
719
|
+
reject(new Error("Login timed out after 5 minutes"));
|
|
655
720
|
}, 5 * 60 * 1000);
|
|
656
721
|
});
|
|
657
722
|
// 5. Open the browser to the server's /authorize endpoint with PKCE params
|
|
658
|
-
const authorizeUrl = new URL(
|
|
659
|
-
authorizeUrl.searchParams.set(
|
|
660
|
-
authorizeUrl.searchParams.set(
|
|
661
|
-
authorizeUrl.searchParams.set(
|
|
662
|
-
authorizeUrl.searchParams.set(
|
|
663
|
-
authorizeUrl.searchParams.set(
|
|
664
|
-
authorizeUrl.searchParams.set(
|
|
665
|
-
console.log(
|
|
723
|
+
const authorizeUrl = new URL("/authorize", SERVER_BASE);
|
|
724
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
725
|
+
authorizeUrl.searchParams.set("client_id", clientId);
|
|
726
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
727
|
+
authorizeUrl.searchParams.set("code_challenge", codeChallenge);
|
|
728
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
729
|
+
authorizeUrl.searchParams.set("state", state);
|
|
730
|
+
console.log(" Opening browser for authentication...");
|
|
666
731
|
console.log(` If it doesn't open, visit:`);
|
|
667
732
|
console.log(` ${authorizeUrl.toString()}`);
|
|
668
|
-
console.log(
|
|
669
|
-
const { exec } = await import(
|
|
670
|
-
const openCmd = process.platform ===
|
|
733
|
+
console.log("");
|
|
734
|
+
const { exec } = await import("child_process");
|
|
735
|
+
const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
671
736
|
exec(`${openCmd} "${authorizeUrl.toString()}"`);
|
|
672
|
-
console.log(
|
|
737
|
+
console.log(" Waiting for authorization...");
|
|
673
738
|
let authCode;
|
|
674
739
|
try {
|
|
675
740
|
authCode = await codePromise;
|
|
@@ -688,10 +753,10 @@ export async function loginBrowser(opts = {}) {
|
|
|
688
753
|
let expiresIn = 31536000; // server default = 1 year
|
|
689
754
|
try {
|
|
690
755
|
const tokenRes = await fetch(`${SERVER_BASE}/token`, {
|
|
691
|
-
method:
|
|
692
|
-
headers: {
|
|
756
|
+
method: "POST",
|
|
757
|
+
headers: { "Content-Type": "application/json" },
|
|
693
758
|
body: JSON.stringify({
|
|
694
|
-
grant_type:
|
|
759
|
+
grant_type: "authorization_code",
|
|
695
760
|
code: authCode,
|
|
696
761
|
code_verifier: codeVerifier,
|
|
697
762
|
client_id: clientId,
|
|
@@ -704,7 +769,7 @@ export async function loginBrowser(opts = {}) {
|
|
|
704
769
|
throw new Error(payload.error_description || payload.error || `HTTP ${tokenRes.status}`);
|
|
705
770
|
}
|
|
706
771
|
accessToken = payload.access_token;
|
|
707
|
-
if (typeof payload.expires_in ===
|
|
772
|
+
if (typeof payload.expires_in === "number")
|
|
708
773
|
expiresIn = payload.expires_in;
|
|
709
774
|
}
|
|
710
775
|
catch (e) {
|
|
@@ -715,25 +780,31 @@ export async function loginBrowser(opts = {}) {
|
|
|
715
780
|
// 7. Save the opaque token — same shape as the device flow
|
|
716
781
|
const updated = readConfig();
|
|
717
782
|
updated.token = accessToken;
|
|
718
|
-
updated.token_type =
|
|
783
|
+
updated.token_type = "oauth";
|
|
719
784
|
updated.authenticated_at = new Date().toISOString();
|
|
720
785
|
updated.server_url = SERVER_BASE;
|
|
721
786
|
updated.dashboard_url = DASHBOARD_BASE;
|
|
722
787
|
updated.token_expires_at = new Date(Date.now() + expiresIn * 1000).toISOString();
|
|
723
788
|
if (clientId)
|
|
724
789
|
updated.oauth_client_id = clientId;
|
|
725
|
-
const [browserIdentity] = await Promise.all([
|
|
790
|
+
const [browserIdentity] = await Promise.all([
|
|
791
|
+
fetchUserIdentity(accessToken),
|
|
792
|
+
clearReauthFlag(accessToken),
|
|
793
|
+
]);
|
|
726
794
|
if (browserIdentity.user_id)
|
|
727
795
|
updated.user_id = browserIdentity.user_id;
|
|
728
796
|
if (browserIdentity.email)
|
|
729
797
|
updated.email = browserIdentity.email;
|
|
798
|
+
if (browserIdentity.name && !updated.user?.name) {
|
|
799
|
+
updated.user = { ...(updated.user ?? {}), name: browserIdentity.name };
|
|
800
|
+
}
|
|
730
801
|
writeConfig(updated);
|
|
731
802
|
killStaleMcpServers();
|
|
732
|
-
await
|
|
733
|
-
console.log(
|
|
734
|
-
console.log(
|
|
735
|
-
console.log(
|
|
736
|
-
console.log(
|
|
803
|
+
await runPostLoginOnboarding();
|
|
804
|
+
console.log("");
|
|
805
|
+
console.log(" ✓ Authenticated successfully");
|
|
806
|
+
console.log(" Token saved to ~/.gramatr.json");
|
|
807
|
+
console.log(" gramatr intelligence is now active.\n");
|
|
737
808
|
}
|
|
738
809
|
// ── CLI ──
|
|
739
810
|
//
|
|
@@ -744,30 +815,33 @@ export async function loginBrowser(opts = {}) {
|
|
|
744
815
|
// safe even if the package ever loses `"type": "module"` or tsx
|
|
745
816
|
// changes its default target to CJS.
|
|
746
817
|
export async function main(rawArgs = process.argv.slice(2)) {
|
|
747
|
-
const args = rawArgs[0] ===
|
|
748
|
-
if (args.includes(
|
|
818
|
+
const args = rawArgs[0] === "login" ? rawArgs.slice(1) : rawArgs;
|
|
819
|
+
if (args.includes("--status") || args.includes("status")) {
|
|
749
820
|
await showStatus();
|
|
750
821
|
return;
|
|
751
822
|
}
|
|
752
|
-
if (args.includes(
|
|
823
|
+
if (args.includes("--logout") || args.includes("logout")) {
|
|
753
824
|
await logout();
|
|
754
825
|
return;
|
|
755
826
|
}
|
|
756
|
-
if (args.includes(
|
|
757
|
-
const tokenIdx = args.indexOf(
|
|
827
|
+
if (args.includes("--token") || args.includes("-t")) {
|
|
828
|
+
const tokenIdx = args.indexOf("--token") !== -1 ? args.indexOf("--token") : args.indexOf("-t");
|
|
758
829
|
const token = args[tokenIdx + 1];
|
|
759
830
|
if (!token) {
|
|
760
831
|
// Interactive paste mode — like Claude's login
|
|
761
|
-
console.log(
|
|
762
|
-
console.log(
|
|
763
|
-
process.stdout.write(
|
|
764
|
-
const { createInterface } = await import(
|
|
832
|
+
console.log("\n Paste your gramatr token below.");
|
|
833
|
+
console.log(" (API keys start with sk_)\n");
|
|
834
|
+
process.stdout.write(" Token: ");
|
|
835
|
+
const { createInterface } = await import("readline");
|
|
765
836
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
766
837
|
const pastedToken = await new Promise((resolve) => {
|
|
767
|
-
rl.on(
|
|
838
|
+
rl.on("line", (line) => {
|
|
839
|
+
rl.close();
|
|
840
|
+
resolve(line.trim());
|
|
841
|
+
});
|
|
768
842
|
});
|
|
769
843
|
if (!pastedToken) {
|
|
770
|
-
console.log(
|
|
844
|
+
console.log(" No token provided.\n");
|
|
771
845
|
process.exit(1);
|
|
772
846
|
}
|
|
773
847
|
await loginWithToken(pastedToken);
|
|
@@ -777,7 +851,7 @@ export async function main(rawArgs = process.argv.slice(2)) {
|
|
|
777
851
|
}
|
|
778
852
|
return;
|
|
779
853
|
}
|
|
780
|
-
if (args.includes(
|
|
854
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
781
855
|
console.log(`
|
|
782
856
|
gramatr login — Authenticate with the gramatr server
|
|
783
857
|
|
|
@@ -802,17 +876,17 @@ export async function main(rawArgs = process.argv.slice(2)) {
|
|
|
802
876
|
}
|
|
803
877
|
// Default: browser login flow (falls back to device flow if headless
|
|
804
878
|
// detected, OR if --headless / GRAMATR_LOGIN_HEADLESS=1 is set).
|
|
805
|
-
const forceHeadless = args.includes(
|
|
879
|
+
const forceHeadless = args.includes("--headless");
|
|
806
880
|
await loginBrowser({ forceHeadless });
|
|
807
881
|
}
|
|
808
882
|
// Module-run guard. Works both when invoked directly via
|
|
809
883
|
// `tsx bin/login.ts` and when imported from another module
|
|
810
884
|
// (tests, programmatic use). Under ESM, import.meta.url is the
|
|
811
885
|
// canonical check; we also accept a path-suffix match as a belt.
|
|
812
|
-
const invokedAs = process.argv[1] ||
|
|
886
|
+
const invokedAs = process.argv[1] || "";
|
|
813
887
|
const isMain = import.meta.url === `file://${invokedAs}` ||
|
|
814
|
-
invokedAs.endsWith(
|
|
815
|
-
invokedAs.endsWith(
|
|
888
|
+
invokedAs.endsWith("login.ts") ||
|
|
889
|
+
invokedAs.endsWith("login.js");
|
|
816
890
|
if (isMain) {
|
|
817
891
|
main().catch((err) => {
|
|
818
892
|
console.error(err instanceof Error ? err.message : String(err));
|