@gramatr/client 0.5.1
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/AGENTS.md +17 -0
- package/CLAUDE.md +18 -0
- package/README.md +108 -0
- package/bin/add-api-key.ts +264 -0
- package/bin/clean-legacy-install.ts +28 -0
- package/bin/clear-creds.ts +141 -0
- package/bin/get-token.py +3 -0
- package/bin/gmtr-login.ts +599 -0
- package/bin/gramatr.js +36 -0
- package/bin/gramatr.ts +374 -0
- package/bin/install.ts +716 -0
- package/bin/lib/config.ts +57 -0
- package/bin/lib/git.ts +111 -0
- package/bin/lib/stdin.ts +53 -0
- package/bin/logout.ts +76 -0
- package/bin/render-claude-hooks.ts +16 -0
- package/bin/statusline.ts +81 -0
- package/bin/uninstall.ts +289 -0
- package/chatgpt/README.md +95 -0
- package/chatgpt/install.ts +140 -0
- package/chatgpt/lib/chatgpt-install-utils.ts +89 -0
- package/codex/README.md +28 -0
- package/codex/hooks/session-start.ts +73 -0
- package/codex/hooks/stop.ts +34 -0
- package/codex/hooks/user-prompt-submit.ts +79 -0
- package/codex/install.ts +116 -0
- package/codex/lib/codex-hook-utils.ts +48 -0
- package/codex/lib/codex-install-utils.ts +123 -0
- package/core/auth.ts +170 -0
- package/core/feedback.ts +55 -0
- package/core/formatting.ts +179 -0
- package/core/install.ts +107 -0
- package/core/installer-cli.ts +122 -0
- package/core/migration.ts +479 -0
- package/core/routing.ts +108 -0
- package/core/session.ts +202 -0
- package/core/targets.ts +292 -0
- package/core/types.ts +179 -0
- package/core/version-check.ts +219 -0
- package/core/version.ts +47 -0
- package/desktop/README.md +72 -0
- package/desktop/build-mcpb.ts +166 -0
- package/desktop/install.ts +136 -0
- package/desktop/lib/desktop-install-utils.ts +70 -0
- package/gemini/README.md +95 -0
- package/gemini/hooks/session-start.ts +72 -0
- package/gemini/hooks/stop.ts +30 -0
- package/gemini/hooks/user-prompt-submit.ts +77 -0
- package/gemini/install.ts +281 -0
- package/gemini/lib/gemini-hook-utils.ts +63 -0
- package/gemini/lib/gemini-install-utils.ts +169 -0
- package/hooks/GMTRPromptEnricher.hook.ts +651 -0
- package/hooks/GMTRRatingCapture.hook.ts +198 -0
- package/hooks/GMTRSecurityValidator.hook.ts +399 -0
- package/hooks/GMTRToolTracker.hook.ts +181 -0
- package/hooks/StopOrchestrator.hook.ts +78 -0
- package/hooks/gmtr-tool-tracker-utils.ts +105 -0
- package/hooks/lib/gmtr-hook-utils.ts +770 -0
- package/hooks/lib/identity.ts +227 -0
- package/hooks/lib/notify.ts +46 -0
- package/hooks/lib/paths.ts +104 -0
- package/hooks/lib/transcript-parser.ts +452 -0
- package/hooks/session-end.hook.ts +168 -0
- package/hooks/session-start.hook.ts +501 -0
- package/package.json +63 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gmtr-login — Authenticate with the gramatr server
|
|
4
|
+
*
|
|
5
|
+
* Opens the grāmatr dashboard login flow, captures a Firebase ID token
|
|
6
|
+
* on localhost, and stores it in ~/.gmtr.json.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* bun gmtr-login.ts # Interactive browser login via Firebase dashboard
|
|
10
|
+
* bun gmtr-login.ts --token <token> # Paste a token directly (API key or Firebase token)
|
|
11
|
+
* bun gmtr-login.ts --status # Check current auth status
|
|
12
|
+
* bun gmtr-login.ts --logout # Remove stored credentials
|
|
13
|
+
*
|
|
14
|
+
* Token is stored in ~/.gmtr.json under the "token" key.
|
|
15
|
+
* The GMTRPromptEnricher hook reads this on every prompt.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomBytes } from 'crypto';
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'http';
|
|
22
|
+
|
|
23
|
+
// ── Config ──
|
|
24
|
+
|
|
25
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || '';
|
|
26
|
+
const CONFIG_PATH = join(HOME, '.gmtr.json');
|
|
27
|
+
const DEFAULT_SERVER = process.env.GMTR_URL || 'https://api.gramatr.com/mcp';
|
|
28
|
+
// Strip /mcp suffix to get base URL
|
|
29
|
+
const SERVER_BASE = DEFAULT_SERVER.replace(/\/mcp\/?$/, '');
|
|
30
|
+
const DASHBOARD_BASE = process.env.GMTR_DASHBOARD_URL || (() => {
|
|
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(/\/$/, '');
|
|
40
|
+
} catch {
|
|
41
|
+
return 'https://app.gramatr.com';
|
|
42
|
+
}
|
|
43
|
+
})();
|
|
44
|
+
const CALLBACK_PORT = 58787; // Must match server's redirect_uris
|
|
45
|
+
|
|
46
|
+
// ── HTML Templates ──
|
|
47
|
+
|
|
48
|
+
const BRAND_CSS = `
|
|
49
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
50
|
+
body {
|
|
51
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
52
|
+
background: #0a0e17;
|
|
53
|
+
color: #e0e6ed;
|
|
54
|
+
min-height: 100vh;
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
justify-content: center;
|
|
58
|
+
}
|
|
59
|
+
.card {
|
|
60
|
+
background: #141b2d;
|
|
61
|
+
border: 1px solid #1e2940;
|
|
62
|
+
border-radius: 16px;
|
|
63
|
+
padding: 48px;
|
|
64
|
+
max-width: 440px;
|
|
65
|
+
width: 90%;
|
|
66
|
+
text-align: center;
|
|
67
|
+
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
|
68
|
+
}
|
|
69
|
+
.logo {
|
|
70
|
+
font-size: 28px;
|
|
71
|
+
font-weight: 700;
|
|
72
|
+
letter-spacing: -0.5px;
|
|
73
|
+
margin-bottom: 8px;
|
|
74
|
+
}
|
|
75
|
+
.logo .accent { color: #00b4d8; }
|
|
76
|
+
.logo .dim { color: #5a6a8a; }
|
|
77
|
+
.subtitle {
|
|
78
|
+
color: #5a6a8a;
|
|
79
|
+
font-size: 13px;
|
|
80
|
+
margin-bottom: 32px;
|
|
81
|
+
}
|
|
82
|
+
.status {
|
|
83
|
+
font-size: 48px;
|
|
84
|
+
margin-bottom: 16px;
|
|
85
|
+
}
|
|
86
|
+
h2 {
|
|
87
|
+
font-size: 20px;
|
|
88
|
+
font-weight: 600;
|
|
89
|
+
margin-bottom: 12px;
|
|
90
|
+
}
|
|
91
|
+
h2.success { color: #00b4d8; }
|
|
92
|
+
h2.error { color: #e74c3c; }
|
|
93
|
+
p {
|
|
94
|
+
color: #7a8aaa;
|
|
95
|
+
font-size: 14px;
|
|
96
|
+
line-height: 1.6;
|
|
97
|
+
}
|
|
98
|
+
.hint {
|
|
99
|
+
margin-top: 24px;
|
|
100
|
+
padding-top: 24px;
|
|
101
|
+
border-top: 1px solid #1e2940;
|
|
102
|
+
font-size: 12px;
|
|
103
|
+
color: #4a5a7a;
|
|
104
|
+
}
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
function htmlPage(title: string, body: string): string {
|
|
108
|
+
return `<!DOCTYPE html>
|
|
109
|
+
<html lang="en"><head>
|
|
110
|
+
<meta charset="utf-8">
|
|
111
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
112
|
+
<title>${title} — gramatr</title>
|
|
113
|
+
<style>${BRAND_CSS}</style>
|
|
114
|
+
</head><body>
|
|
115
|
+
<div class="card">
|
|
116
|
+
<div class="logo"><span class="accent">gr</span>āma<span class="accent">tr</span></div>
|
|
117
|
+
<div class="subtitle">your cross-agent AI brain</div>
|
|
118
|
+
${body}
|
|
119
|
+
</div>
|
|
120
|
+
</body></html>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function successPage(): string {
|
|
124
|
+
return htmlPage('Authenticated', `
|
|
125
|
+
<div class="status">✓</div>
|
|
126
|
+
<h2 class="success">Authenticated</h2>
|
|
127
|
+
<p>Token saved. You can close this tab and return to your terminal.</p>
|
|
128
|
+
<div class="hint">gramatr intelligence is now active across all your AI tools.</div>
|
|
129
|
+
`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function errorPage(title: string, detail: string): string {
|
|
133
|
+
return htmlPage('Error', `
|
|
134
|
+
<div class="status">✗</div>
|
|
135
|
+
<h2 class="error">${title}</h2>
|
|
136
|
+
<p>${detail}</p>
|
|
137
|
+
<div class="hint">Return to your terminal and try again, or use <code>gmtr-login --token</code> to paste a token directly.</div>
|
|
138
|
+
`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Headless Detection ──
|
|
142
|
+
|
|
143
|
+
function isHeadless(): boolean {
|
|
144
|
+
// SSH session without display forwarding
|
|
145
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY) {
|
|
146
|
+
if (!process.env.DISPLAY && process.platform !== 'darwin') return true;
|
|
147
|
+
}
|
|
148
|
+
// Docker / CI / no TTY
|
|
149
|
+
if (process.env.CI || process.env.DOCKER) return true;
|
|
150
|
+
// Linux without display
|
|
151
|
+
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return true;
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Helpers ──
|
|
156
|
+
|
|
157
|
+
function readConfig(): Record<string, any> {
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
160
|
+
} catch {
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function writeConfig(config: Record<string, any>): void {
|
|
166
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function checkServerHealth(): Promise<{ ok: boolean; version?: string; error?: string }> {
|
|
170
|
+
try {
|
|
171
|
+
const res = await fetch(`${SERVER_BASE}/health`, { signal: AbortSignal.timeout(5000) });
|
|
172
|
+
if (res.ok) {
|
|
173
|
+
const data = await res.json() as any;
|
|
174
|
+
return { ok: true, version: data.version };
|
|
175
|
+
}
|
|
176
|
+
return { ok: false, error: `HTTP ${res.status}` };
|
|
177
|
+
} catch (e: any) {
|
|
178
|
+
return { ok: false, error: e.message };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function testToken(token: string): Promise<{ valid: boolean; user?: string; error?: string }> {
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetch(`${SERVER_BASE}/mcp`, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: {
|
|
187
|
+
'Content-Type': 'application/json',
|
|
188
|
+
Accept: 'application/json, text/event-stream',
|
|
189
|
+
Authorization: `Bearer ${token}`,
|
|
190
|
+
},
|
|
191
|
+
body: JSON.stringify({
|
|
192
|
+
jsonrpc: '2.0',
|
|
193
|
+
id: 1,
|
|
194
|
+
method: 'tools/call',
|
|
195
|
+
params: { name: 'aggregate_stats', arguments: {} },
|
|
196
|
+
}),
|
|
197
|
+
signal: AbortSignal.timeout(10000),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const text = await res.text();
|
|
201
|
+
|
|
202
|
+
// Check for auth errors
|
|
203
|
+
if (text.includes('JWT token is required') || text.includes('signature validation failed') || text.includes('Unauthorized')) {
|
|
204
|
+
return { valid: false, error: 'Token rejected by server' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check for successful response
|
|
208
|
+
for (const line of text.split('\n')) {
|
|
209
|
+
if (line.startsWith('data: ')) {
|
|
210
|
+
try {
|
|
211
|
+
const d = JSON.parse(line.slice(6));
|
|
212
|
+
if (d?.result?.isError) {
|
|
213
|
+
return { valid: false, error: d.result.content?.[0]?.text || 'Unknown error' };
|
|
214
|
+
}
|
|
215
|
+
if (d?.result?.content?.[0]?.text) {
|
|
216
|
+
return { valid: true, user: 'authenticated' };
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { valid: false, error: 'Unexpected response' };
|
|
225
|
+
} catch (e: any) {
|
|
226
|
+
return { valid: false, error: e.message };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function startDeviceAuthorization(): Promise<{
|
|
231
|
+
device_code: string;
|
|
232
|
+
user_code: string;
|
|
233
|
+
verification_uri: string;
|
|
234
|
+
verification_uri_complete?: string;
|
|
235
|
+
expires_in: number;
|
|
236
|
+
interval: number;
|
|
237
|
+
}> {
|
|
238
|
+
const res = await fetch(`${SERVER_BASE}/device/start`, {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: { 'Content-Type': 'application/json' },
|
|
241
|
+
body: JSON.stringify({ client_name: 'gmtr-login' }),
|
|
242
|
+
signal: AbortSignal.timeout(10000),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const payload = await res.json().catch(() => ({}));
|
|
246
|
+
if (!res.ok) {
|
|
247
|
+
throw new Error(payload.error_description || payload.error || `HTTP ${res.status}`);
|
|
248
|
+
}
|
|
249
|
+
return payload;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function pollDeviceAuthorization(deviceCode: string): Promise<string> {
|
|
253
|
+
while (true) {
|
|
254
|
+
const res = await fetch(`${SERVER_BASE}/device/token`, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: { 'Content-Type': 'application/json' },
|
|
257
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
258
|
+
signal: AbortSignal.timeout(10000),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const payload = await res.json().catch(() => ({}));
|
|
262
|
+
if (res.ok && payload.access_token) {
|
|
263
|
+
return payload.access_token as string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if ((res.status === 428 || res.status === 400) && payload.error === 'authorization_pending') {
|
|
267
|
+
const waitSeconds = Math.max(1, Number(payload.interval) || 5);
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
throw new Error(payload.error_description || payload.error || `HTTP ${res.status}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Commands ──
|
|
277
|
+
|
|
278
|
+
async function showStatus(): Promise<void> {
|
|
279
|
+
console.log('\n gramatr authentication status\n');
|
|
280
|
+
|
|
281
|
+
const config = readConfig();
|
|
282
|
+
const token = config.token;
|
|
283
|
+
|
|
284
|
+
console.log(` Server: ${SERVER_BASE}`);
|
|
285
|
+
|
|
286
|
+
const health = await checkServerHealth();
|
|
287
|
+
if (health.ok) {
|
|
288
|
+
console.log(` Health: ✓ healthy (v${health.version || 'unknown'})`);
|
|
289
|
+
} else {
|
|
290
|
+
console.log(` Health: ✗ ${health.error}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!token) {
|
|
294
|
+
console.log(' Token: ✗ not configured');
|
|
295
|
+
console.log('\n Run: bun gmtr-login.ts to authenticate\n');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const prefix = token.substring(0, 15);
|
|
300
|
+
console.log(` Token: ${prefix}...`);
|
|
301
|
+
|
|
302
|
+
const result = await testToken(token);
|
|
303
|
+
if (result.valid) {
|
|
304
|
+
console.log(' Auth: ✓ token is valid');
|
|
305
|
+
} else {
|
|
306
|
+
console.log(` Auth: ✗ ${result.error}`);
|
|
307
|
+
console.log('\n Run: bun gmtr-login.ts to re-authenticate\n');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
console.log('');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function logout(): Promise<void> {
|
|
314
|
+
const config = readConfig();
|
|
315
|
+
delete config.token;
|
|
316
|
+
delete config.token_type;
|
|
317
|
+
delete config.token_expires;
|
|
318
|
+
delete config.authenticated_at;
|
|
319
|
+
writeConfig(config);
|
|
320
|
+
console.log('\n ✓ Logged out. Token removed from ~/.gmtr.json\n');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function loginWithToken(token: string): Promise<void> {
|
|
324
|
+
console.log('\n Testing token...');
|
|
325
|
+
|
|
326
|
+
const result = await testToken(token);
|
|
327
|
+
if (result.valid) {
|
|
328
|
+
const config = readConfig();
|
|
329
|
+
config.token = token;
|
|
330
|
+
config.token_type = token.startsWith('aios_sk_') || token.startsWith('gmtr_sk_') ? 'api_key' : 'oauth';
|
|
331
|
+
config.authenticated_at = new Date().toISOString();
|
|
332
|
+
writeConfig(config);
|
|
333
|
+
console.log(' ✓ Token valid. Saved to ~/.gmtr.json');
|
|
334
|
+
console.log(' gramatr intelligence is now active.\n');
|
|
335
|
+
} else {
|
|
336
|
+
console.log(` ✗ Token rejected: ${result.error}`);
|
|
337
|
+
console.log(' Token was NOT saved.\n');
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function loginBrowser(): Promise<void> {
|
|
343
|
+
console.log('\n gramatr login\n');
|
|
344
|
+
console.log(` Server: ${SERVER_BASE}`);
|
|
345
|
+
console.log(` Dashboard: ${DASHBOARD_BASE}`);
|
|
346
|
+
|
|
347
|
+
// Check server health first
|
|
348
|
+
const health = await checkServerHealth();
|
|
349
|
+
if (!health.ok) {
|
|
350
|
+
console.log(` ✗ Server unreachable: ${health.error}`);
|
|
351
|
+
console.log(' Cannot authenticate. Is the server running?\n');
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
console.log(` Health: ✓ v${health.version || 'unknown'}`);
|
|
355
|
+
|
|
356
|
+
console.log('');
|
|
357
|
+
|
|
358
|
+
// Headless environments use device auth (no local server needed)
|
|
359
|
+
if (isHeadless()) {
|
|
360
|
+
console.log(' Headless environment detected. Starting device login...\n');
|
|
361
|
+
try {
|
|
362
|
+
const device = await startDeviceAuthorization();
|
|
363
|
+
console.log(` Code: ${device.user_code}`);
|
|
364
|
+
console.log(` Open: ${device.verification_uri_complete || device.verification_uri}`);
|
|
365
|
+
console.log(' Sign in with Google or GitHub, approve the device, then return here.\n');
|
|
366
|
+
console.log(' Waiting for authorization...');
|
|
367
|
+
|
|
368
|
+
// v0.3.63 hotfix: must clear the timeout after the race resolves,
|
|
369
|
+
// otherwise the orphan setTimeout keeps the Node event loop alive
|
|
370
|
+
// until expires_in elapses (typically 600s). Symptom: success path
|
|
371
|
+
// prints "Authenticated successfully" and then hangs until Ctrl+C.
|
|
372
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
373
|
+
const timeoutPromise = new Promise<string>((_, reject) => {
|
|
374
|
+
timeoutHandle = setTimeout(
|
|
375
|
+
() => reject(new Error('Device login timed out')),
|
|
376
|
+
device.expires_in * 1000,
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
let accessToken: string;
|
|
380
|
+
try {
|
|
381
|
+
accessToken = await Promise.race([
|
|
382
|
+
pollDeviceAuthorization(device.device_code),
|
|
383
|
+
timeoutPromise,
|
|
384
|
+
]);
|
|
385
|
+
} finally {
|
|
386
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const config = readConfig();
|
|
390
|
+
config.token = accessToken;
|
|
391
|
+
config.token_type = 'oauth';
|
|
392
|
+
config.authenticated_at = new Date().toISOString();
|
|
393
|
+
config.server_url = SERVER_BASE;
|
|
394
|
+
config.dashboard_url = DASHBOARD_BASE;
|
|
395
|
+
writeConfig(config);
|
|
396
|
+
|
|
397
|
+
console.log('');
|
|
398
|
+
console.log(' ✓ Authenticated successfully');
|
|
399
|
+
console.log(' Token saved to ~/.gmtr.json');
|
|
400
|
+
console.log(' gramatr intelligence is now active.\n');
|
|
401
|
+
return;
|
|
402
|
+
} catch (e: any) {
|
|
403
|
+
console.log(` ✗ Device login failed: ${e.message}`);
|
|
404
|
+
console.log(' Fallback: gmtr-login --token\n');
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Browser environments use local callback server
|
|
410
|
+
const state = randomBytes(16).toString('base64url');
|
|
411
|
+
const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
|
|
412
|
+
|
|
413
|
+
const tokenPromise = new Promise<string>((resolve, reject) => {
|
|
414
|
+
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
415
|
+
const url = new URL(req.url || '/', `http://localhost:${CALLBACK_PORT}`);
|
|
416
|
+
|
|
417
|
+
if (url.pathname !== '/callback') {
|
|
418
|
+
res.writeHead(404);
|
|
419
|
+
res.end('Not found');
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const token = url.searchParams.get('token');
|
|
424
|
+
const returnedState = url.searchParams.get('state');
|
|
425
|
+
const error = url.searchParams.get('error');
|
|
426
|
+
|
|
427
|
+
if (error) {
|
|
428
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
429
|
+
res.end(errorPage('Authentication Failed', error));
|
|
430
|
+
server.close();
|
|
431
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!token || returnedState !== state) {
|
|
436
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
437
|
+
res.end(errorPage('Invalid Callback', 'Missing Firebase token or state mismatch. Please try again.'));
|
|
438
|
+
server.close();
|
|
439
|
+
reject(new Error('Invalid callback'));
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
const validation = await testToken(token);
|
|
445
|
+
if (!validation.valid) {
|
|
446
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
447
|
+
res.end(errorPage('Token Validation Failed', validation.error || 'Server rejected token'));
|
|
448
|
+
server.close();
|
|
449
|
+
reject(new Error(validation.error || 'Server rejected token'));
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
454
|
+
res.end(successPage());
|
|
455
|
+
server.close();
|
|
456
|
+
resolve(token);
|
|
457
|
+
} catch (e: any) {
|
|
458
|
+
res.writeHead(500, { 'Content-Type': 'text/html' });
|
|
459
|
+
res.end(errorPage('Unexpected Error', e.message));
|
|
460
|
+
server.close();
|
|
461
|
+
reject(e);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
server.listen(CALLBACK_PORT, () => {
|
|
466
|
+
// Server ready
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// Timeout after 2 minutes
|
|
470
|
+
setTimeout(() => {
|
|
471
|
+
server.close();
|
|
472
|
+
reject(new Error('Login timed out after 2 minutes'));
|
|
473
|
+
}, 120000);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
const authorizeUrl = new URL('/login', `${DASHBOARD_BASE}/`);
|
|
477
|
+
authorizeUrl.searchParams.set('callback', callbackUrl);
|
|
478
|
+
authorizeUrl.searchParams.set('state', state);
|
|
479
|
+
|
|
480
|
+
console.log(' Opening browser for authentication...');
|
|
481
|
+
console.log(` If it doesn't open, visit:`);
|
|
482
|
+
console.log(` ${authorizeUrl.toString()}`);
|
|
483
|
+
console.log('');
|
|
484
|
+
|
|
485
|
+
// Open browser
|
|
486
|
+
const { exec } = await import('child_process');
|
|
487
|
+
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
488
|
+
exec(`${openCmd} "${authorizeUrl.toString()}"`);
|
|
489
|
+
|
|
490
|
+
console.log(' Waiting for authorization...');
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
const accessToken = await tokenPromise;
|
|
494
|
+
|
|
495
|
+
// Save token
|
|
496
|
+
const config = readConfig();
|
|
497
|
+
config.token = accessToken;
|
|
498
|
+
config.token_type = 'firebase';
|
|
499
|
+
config.authenticated_at = new Date().toISOString();
|
|
500
|
+
config.server_url = SERVER_BASE;
|
|
501
|
+
config.dashboard_url = DASHBOARD_BASE;
|
|
502
|
+
writeConfig(config);
|
|
503
|
+
|
|
504
|
+
console.log('');
|
|
505
|
+
console.log(' ✓ Authenticated successfully');
|
|
506
|
+
console.log(' Token saved to ~/.gmtr.json');
|
|
507
|
+
console.log(' gramatr intelligence is now active.\n');
|
|
508
|
+
} catch (e: any) {
|
|
509
|
+
console.log(`\n ✗ Authentication failed: ${e.message}\n`);
|
|
510
|
+
process.exit(1);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── CLI ──
|
|
515
|
+
//
|
|
516
|
+
// Defense in depth (Fix A' for Windows top-level await crash):
|
|
517
|
+
// All CLI code is wrapped in an async `main()` and invoked via a
|
|
518
|
+
// module-run guard. This keeps the module importable without firing
|
|
519
|
+
// side effects and avoids top-level await entirely, so the file stays
|
|
520
|
+
// safe even if the package ever loses `"type": "module"` or tsx
|
|
521
|
+
// changes its default target to CJS.
|
|
522
|
+
|
|
523
|
+
export async function main(): Promise<void> {
|
|
524
|
+
const args = process.argv.slice(2);
|
|
525
|
+
|
|
526
|
+
if (args.includes('--status') || args.includes('status')) {
|
|
527
|
+
await showStatus();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (args.includes('--logout') || args.includes('logout')) {
|
|
532
|
+
await logout();
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (args.includes('--token') || args.includes('-t')) {
|
|
537
|
+
const tokenIdx = args.indexOf('--token') !== -1 ? args.indexOf('--token') : args.indexOf('-t');
|
|
538
|
+
const token = args[tokenIdx + 1];
|
|
539
|
+
if (!token) {
|
|
540
|
+
// Interactive paste mode — like Claude's login
|
|
541
|
+
console.log('\n Paste your gramatr token below.');
|
|
542
|
+
console.log(' (API keys start with aios_sk_ or gmtr_sk_)\n');
|
|
543
|
+
process.stdout.write(' Token: ');
|
|
544
|
+
|
|
545
|
+
const { createInterface } = await import('readline');
|
|
546
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
547
|
+
const pastedToken = await new Promise<string>((resolve) => {
|
|
548
|
+
rl.on('line', (line: string) => { rl.close(); resolve(line.trim()); });
|
|
549
|
+
});
|
|
550
|
+
if (!pastedToken) {
|
|
551
|
+
console.log(' No token provided.\n');
|
|
552
|
+
process.exit(1);
|
|
553
|
+
}
|
|
554
|
+
await loginWithToken(pastedToken);
|
|
555
|
+
} else {
|
|
556
|
+
await loginWithToken(token);
|
|
557
|
+
}
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
562
|
+
console.log(`
|
|
563
|
+
gmtr-login — Authenticate with the gramatr server
|
|
564
|
+
|
|
565
|
+
Usage:
|
|
566
|
+
gmtr-login Interactive dashboard login (browser or headless device flow)
|
|
567
|
+
gmtr-login --token Paste a token (API key or Firebase token)
|
|
568
|
+
gmtr-login --token <t> Provide token directly
|
|
569
|
+
gmtr-login --status Check authentication status
|
|
570
|
+
gmtr-login --logout Remove stored credentials
|
|
571
|
+
gmtr-login --help Show this help
|
|
572
|
+
|
|
573
|
+
Token storage: ~/.gmtr.json
|
|
574
|
+
Server: ${SERVER_BASE}
|
|
575
|
+
Dashboard: ${DASHBOARD_BASE}
|
|
576
|
+
`);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Default: browser login flow
|
|
581
|
+
await loginBrowser();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Module-run guard. Works both when invoked directly via
|
|
585
|
+
// `tsx bin/gmtr-login.ts` and when imported from another module
|
|
586
|
+
// (tests, programmatic use). Under ESM, import.meta.url is the
|
|
587
|
+
// canonical check; we also accept a path-suffix match as a belt.
|
|
588
|
+
const invokedAs = process.argv[1] || '';
|
|
589
|
+
const isMain =
|
|
590
|
+
import.meta.url === `file://${invokedAs}` ||
|
|
591
|
+
invokedAs.endsWith('gmtr-login.ts') ||
|
|
592
|
+
invokedAs.endsWith('gmtr-login.js');
|
|
593
|
+
|
|
594
|
+
if (isMain) {
|
|
595
|
+
main().catch((err) => {
|
|
596
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
597
|
+
process.exit(1);
|
|
598
|
+
});
|
|
599
|
+
}
|
package/bin/gramatr.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gramatr CLI entry point — thin JS wrapper that bootstraps TypeScript.
|
|
4
|
+
* tsx is a production dependency, resolved directly from node_modules.
|
|
5
|
+
*
|
|
6
|
+
* ESM module: packages/client/package.json has "type": "module" (added in
|
|
7
|
+
* PR #487 to support top-level await in bin/gmtr-login.ts). This file must
|
|
8
|
+
* use ESM imports — bare `require()` will throw ReferenceError at load time.
|
|
9
|
+
* v0.3.60 hotfix: #487 converted the .ts bin files but missed this .js twin.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
const script = join(__dirname, 'gramatr.ts');
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
|
|
22
|
+
// gramatr standardizes on `npx tsx` — see issue #468 for the architectural
|
|
23
|
+
// rationale. bun detection was removed because it silently produced broken
|
|
24
|
+
// hook + statusline configs on hosts where the install-time PATH did not
|
|
25
|
+
// match the runtime PATH of spawned subprocesses.
|
|
26
|
+
|
|
27
|
+
// Resolve tsx from node_modules (tsx is a production dependency)
|
|
28
|
+
try {
|
|
29
|
+
const tsxBin = join(dirname(require.resolve('tsx/package.json')), 'dist', 'cli.mjs');
|
|
30
|
+
const r = spawnSync(process.execPath, [tsxBin, script, ...args], { stdio: 'inherit' });
|
|
31
|
+
process.exit(r.status ?? 1);
|
|
32
|
+
} catch {}
|
|
33
|
+
|
|
34
|
+
// Last resort: npx tsx
|
|
35
|
+
const r = spawnSync('npx', ['--yes', 'tsx', script, ...args], { stdio: 'inherit', shell: true });
|
|
36
|
+
process.exit(r.status ?? 1);
|