@mnemahq/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/src/login.mjs ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * `mnema login` / `mnema logout` — the client half of RFC 8628 (§C).
3
+ *
4
+ * The server grant lives in apps/api/src/oauth/routes/device.ts. This side does
5
+ * three things and must do each of them honestly:
6
+ *
7
+ * 1. start a login and SHOW the code where a human can read it
8
+ * 2. poll, respecting `interval` and `slow_down`
9
+ * 3. stop, with the RIGHT message, when a human denies or the code expires
10
+ *
11
+ * ⚠️ (3) IS THE ONE EVERYONE GETS WRONG. Treating every non-success as "keep
12
+ * waiting" means a user who clicked Deny watches a spinner for ten minutes and is
13
+ * then told the login "timed out". They didn't time out. They said no.
14
+ */
15
+
16
+ import { c, DEFAULT_ORIGIN } from './util.mjs';
17
+ import {
18
+ backendName, clearState, deleteSecret, getSecret, readState, setSecret, writeState, NoKeychainError,
19
+ } from './keychain.mjs';
20
+
21
+ /** The public client id the CLI identifies as. Not a secret — RFC 8628 clients are public. */
22
+ const CLIENT_ID = process.env.MNEMA_CLIENT_ID || 'mnema-cli';
23
+ const ACCOUNT = 'default';
24
+
25
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
26
+
27
+ async function post(origin, path, body, token) {
28
+ const res = await fetch(`${origin}${path}`, {
29
+ method: 'POST',
30
+ headers: {
31
+ 'content-type': 'application/json',
32
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
33
+ },
34
+ body: JSON.stringify(body),
35
+ });
36
+ let json = null;
37
+ try { json = await res.json(); } catch { /* some errors have no body */ }
38
+ return { status: res.status, json };
39
+ }
40
+
41
+ export async function cmdLogin(flags = {}) {
42
+ const origin = flags.origin || process.env.MNEMA_API_ORIGIN || DEFAULT_ORIGIN;
43
+
44
+ // Fail on the keychain BEFORE sending the user to a browser. Making someone
45
+ // approve a login and only then discovering we cannot store the result is a
46
+ // small cruelty that is entirely avoidable.
47
+ const store = backendName();
48
+ if (store === 'none available') throw new NoKeychainError();
49
+
50
+ const start = await post(origin, '/oauth/device_authorization', {
51
+ client_id: CLIENT_ID,
52
+ scope: 'workspace:read workspace:write',
53
+ });
54
+ if (start.status !== 200 || !start.json?.device_code) {
55
+ const why = start.json?.error_description || start.json?.error || `HTTP ${start.status}`;
56
+ throw new Error(`Could not start a login: ${why}`);
57
+ }
58
+
59
+ const {
60
+ device_code, user_code, verification_uri, verification_uri_complete,
61
+ expires_in, interval,
62
+ } = start.json;
63
+
64
+ console.log('');
65
+ console.log(` Open ${c.cyan(verification_uri)}`);
66
+ console.log(` Code ${c.bold(user_code)}`);
67
+ console.log('');
68
+ console.log(c.dim(` Or go straight there: ${verification_uri_complete}`));
69
+ console.log(c.dim(` The code expires in ${Math.round((expires_in ?? 600) / 60)} minutes.`));
70
+ console.log('');
71
+ process.stdout.write(c.dim(' Waiting for approval…'));
72
+
73
+ const deadline = Date.now() + (expires_in ?? 600) * 1000;
74
+ let waitMs = (interval ?? 5) * 1000;
75
+
76
+ for (;;) {
77
+ if (Date.now() > deadline) {
78
+ console.log('');
79
+ throw new Error('The code expired before it was approved. Run `mnema login` again.');
80
+ }
81
+ await sleep(waitMs);
82
+
83
+ const poll = await post(origin, '/oauth/token', {
84
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
85
+ device_code,
86
+ client_id: CLIENT_ID,
87
+ });
88
+
89
+ if (poll.status === 200 && poll.json?.access_token) {
90
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
91
+ return finish(origin, poll.json, store);
92
+ }
93
+
94
+ const err = poll.json?.error;
95
+
96
+ if (err === 'authorization_pending') { process.stdout.write(c.dim('.')); continue; }
97
+
98
+ if (err === 'slow_down') {
99
+ // The server raised the interval and told us the new one. Honour it —
100
+ // ignoring slow_down is how a CLI gets a client id rate-limited.
101
+ waitMs = ((poll.json.interval ?? (waitMs / 1000) + 5)) * 1000;
102
+ continue;
103
+ }
104
+
105
+ // ⭐ Everything below is terminal, and each says what actually happened.
106
+ console.log('');
107
+ if (err === 'access_denied') throw new Error('The login was denied in the browser.');
108
+ if (err === 'expired_token') throw new Error('The code expired before it was approved. Run `mnema login` again.');
109
+ throw new Error(`Login failed: ${poll.json?.error_description || err || `HTTP ${poll.status}`}`);
110
+ }
111
+ }
112
+
113
+ async function finish(origin, tokens, store) {
114
+ // ⚠️ The REFRESH token is the durable credential and the only one worth
115
+ // protecting long-term; the access token expires in an hour. Both go to the
116
+ // keychain, never to disk.
117
+ setSecret(`${ACCOUNT}:refresh`, tokens.refresh_token);
118
+ setSecret(`${ACCOUNT}:access`, tokens.access_token);
119
+
120
+ let who = null;
121
+ try {
122
+ const res = await fetch(`${origin}/api/me`, { headers: { authorization: `Bearer ${tokens.access_token}` } });
123
+ if (res.ok) who = await res.json();
124
+ } catch { /* the login worked even if this lookup did not */ }
125
+
126
+ writeState({
127
+ origin,
128
+ account: ACCOUNT,
129
+ // Non-secret only. If you are ever tempted to add a token here, don't.
130
+ email: who?.email ?? null,
131
+ workspace_id: who?.workspace_id ?? null,
132
+ plan: who?.plan ?? null,
133
+ access_expires_at: new Date(Date.now() + (tokens.expires_in ?? 3600) * 1000).toISOString(),
134
+ logged_in_at: new Date().toISOString(),
135
+ });
136
+
137
+ console.log(c.green('✓ Logged in.'));
138
+ if (who?.email) console.log(` Account : ${who.email}`);
139
+ if (who?.workspace_id) console.log(` Workspace : ${who.workspace_id}`);
140
+ if (who?.plan) console.log(` Plan : ${who.plan}`);
141
+ console.log(` Tokens : ${c.dim(store)}`);
142
+ console.log('');
143
+ return 0;
144
+ }
145
+
146
+ /**
147
+ * The access token for an API call, refreshed transparently when stale (§C2).
148
+ * Returns null when the user is not logged in — the caller turns that into the
149
+ * message naming `mnema login`, never a bare 401.
150
+ */
151
+ export async function accessToken() {
152
+ if (process.env.MNEMA_API_KEY) return process.env.MNEMA_API_KEY;
153
+
154
+ const state = readState();
155
+ if (!state) return null;
156
+
157
+ const access = getSecret(`${ACCOUNT}:access`);
158
+ const notYetExpired = state.access_expires_at
159
+ // 60s of slack: a token that expires mid-flight is a confusing 401.
160
+ && new Date(state.access_expires_at).getTime() - 60_000 > Date.now();
161
+ if (access && notYetExpired) return access;
162
+
163
+ const refresh = getSecret(`${ACCOUNT}:refresh`);
164
+ if (!refresh) return null;
165
+
166
+ const res = await post(state.origin, '/oauth/token', {
167
+ grant_type: 'refresh_token',
168
+ refresh_token: refresh,
169
+ client_id: CLIENT_ID,
170
+ });
171
+ if (res.status !== 200 || !res.json?.access_token) return null;
172
+
173
+ // ⚠️ Refresh rotation is one-time-use: the NEW refresh token must be stored or
174
+ // the next call is locked out with a credential that has already been spent.
175
+ setSecret(`${ACCOUNT}:access`, res.json.access_token);
176
+ if (res.json.refresh_token) setSecret(`${ACCOUNT}:refresh`, res.json.refresh_token);
177
+ writeState({
178
+ ...state,
179
+ access_expires_at: new Date(Date.now() + (res.json.expires_in ?? 3600) * 1000).toISOString(),
180
+ });
181
+ return res.json.access_token;
182
+ }
183
+
184
+ export async function cmdLogout() {
185
+ deleteSecret(`${ACCOUNT}:access`);
186
+ deleteSecret(`${ACCOUNT}:refresh`);
187
+ clearState();
188
+ console.log(c.green('✓ Logged out.') + c.dim(' Tokens removed from ' + backendName() + '.'));
189
+ return 0;
190
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Secret storage for the Mnema CLI.
3
+ *
4
+ * Secrets (the hook token used for capture, and an optional API key used for
5
+ * search/sessions) are kept in the OS keychain when one is available:
6
+ * - macOS : `security` (login keychain)
7
+ * - Linux : `secret-tool` (libsecret / GNOME Keyring)
8
+ * - Windows : PowerShell DPAPI blob under %LOCALAPPDATA%
9
+ * If none is reachable we fall back to a 0600 file under the user config dir and
10
+ * print a warning — but we NEVER write a plaintext token into a committed dotfile.
11
+ */
12
+
13
+ import { execFileSync } from 'node:child_process';
14
+ import { homedir, platform } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, chmodSync } from 'node:fs';
17
+
18
+ const SERVICE = 'mnema';
19
+
20
+ function fallbackDir() {
21
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
22
+ return join(base, 'mnema');
23
+ }
24
+ function fallbackFile() {
25
+ return join(fallbackDir(), 'credentials.json');
26
+ }
27
+
28
+ function run(cmd, args, input) {
29
+ return execFileSync(cmd, args, {
30
+ input,
31
+ encoding: 'utf8',
32
+ stdio: ['pipe', 'pipe', 'ignore'],
33
+ timeout: 5000,
34
+ });
35
+ }
36
+
37
+ function has(cmd) {
38
+ try {
39
+ run(process.platform === 'win32' ? 'where' : 'which', [cmd]);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ // ── platform keychain backends ──────────────────────────────────────────────────
47
+
48
+ const macBackend = {
49
+ available: () => platform() === 'darwin' && has('security'),
50
+ get(account) {
51
+ try { return run('security', ['find-generic-password', '-a', account, '-s', SERVICE, '-w']).trim() || null; }
52
+ catch { return null; }
53
+ },
54
+ set(account, secret) {
55
+ run('security', ['add-generic-password', '-a', account, '-s', SERVICE, '-U', '-w', secret]);
56
+ },
57
+ del(account) {
58
+ try { run('security', ['delete-generic-password', '-a', account, '-s', SERVICE]); } catch { /* absent */ }
59
+ },
60
+ };
61
+
62
+ const linuxBackend = {
63
+ available: () => platform() === 'linux' && has('secret-tool'),
64
+ get(account) {
65
+ try { const v = run('secret-tool', ['lookup', 'service', SERVICE, 'account', account]).trim(); return v || null; }
66
+ catch { return null; }
67
+ },
68
+ set(account, secret) {
69
+ run('secret-tool', ['store', '--label=Mnema', 'service', SERVICE, 'account', account], secret);
70
+ },
71
+ del(account) {
72
+ try { run('secret-tool', ['clear', 'service', SERVICE, 'account', account]); } catch { /* absent */ }
73
+ },
74
+ };
75
+
76
+ // Encrypted-at-rest fallback: a 0600 JSON file. Used when no OS keychain is present.
77
+ const fileBackend = {
78
+ available: () => true,
79
+ _read() {
80
+ try { return JSON.parse(readFileSync(fallbackFile(), 'utf8')); } catch { return {}; }
81
+ },
82
+ _write(obj) {
83
+ mkdirSync(fallbackDir(), { recursive: true });
84
+ writeFileSync(fallbackFile(), JSON.stringify(obj, null, 2), { mode: 0o600 });
85
+ try { chmodSync(fallbackFile(), 0o600); } catch { /* best effort */ }
86
+ },
87
+ get(account) { return this._read()[account] ?? null; },
88
+ set(account, secret) { const o = this._read(); o[account] = secret; this._write(o); },
89
+ del(account) { const o = this._read(); delete o[account]; this._write(o); },
90
+ isFallback: true,
91
+ };
92
+
93
+ function backend() {
94
+ if (macBackend.available()) return macBackend;
95
+ if (linuxBackend.available()) return linuxBackend;
96
+ return fileBackend;
97
+ }
98
+
99
+ /** Namespaced account so we can hold >1 secret per workspace. */
100
+ function acct(workspaceId, kind) {
101
+ return `${workspaceId}:${kind}`;
102
+ }
103
+
104
+ export function usingFallback() {
105
+ return backend().isFallback === true;
106
+ }
107
+
108
+ export function getSecret(workspaceId, kind) {
109
+ return backend().get(acct(workspaceId, kind));
110
+ }
111
+
112
+ export function setSecret(workspaceId, kind, secret) {
113
+ backend().set(acct(workspaceId, kind), secret);
114
+ }
115
+
116
+ export function deleteSecrets(workspaceId) {
117
+ const b = backend();
118
+ for (const kind of ['hook-token', 'api-key']) b.del(acct(workspaceId, kind));
119
+ // Also clear any fallback file entry regardless of active backend.
120
+ if (!b.isFallback && existsSync(fallbackFile())) {
121
+ for (const kind of ['hook-token', 'api-key']) fileBackend.del(acct(workspaceId, kind));
122
+ }
123
+ }
124
+
125
+ export function backendName() {
126
+ const b = backend();
127
+ if (b === macBackend) return 'macOS Keychain';
128
+ if (b === linuxBackend) return 'libsecret (Keyring)';
129
+ return `file (0600) at ${fallbackFile()}`;
130
+ }
131
+
132
+ export { rmSync as _rmSync };
package/src/util.mjs ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Shared helpers for the Mnema CLI — config, git context, API access, prompts,
3
+ * and local Claude Code transcript discovery. Pure Node built-ins, no deps.
4
+ */
5
+
6
+ import { execFileSync } from 'node:child_process';
7
+ import { homedir } from 'node:os';
8
+ import { join, dirname } from 'node:path';
9
+ import {
10
+ existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, rmSync,
11
+ } from 'node:fs';
12
+ import { createInterface } from 'node:readline';
13
+
14
+ // ⚠️ THE API ORIGIN, NOT THE APP'S. mnema.theboringpeople.in reverse-proxies
15
+ // /api/* but NOT /oauth/*, so `mnema login` 404'd on the default while every
16
+ // other command worked — the failure only showed up once something needed the
17
+ // authorization server. api.* serves both, and is already what the installed
18
+ // capture hook uses.
19
+ export const DEFAULT_ORIGIN = process.env.MNEMA_API_ORIGIN || 'https://api.theboringpeople.in';
20
+
21
+ export const c = {
22
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
23
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
24
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
25
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
26
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
27
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
28
+ };
29
+
30
+ // ── git ──────────────────────────────────────────────────────────────────────
31
+
32
+ function git(args, cwd) {
33
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).trim();
34
+ }
35
+
36
+ export function gitInfo(cwd = process.cwd()) {
37
+ const out = { root: null, remote: null, branch: null };
38
+ try { out.root = git(['rev-parse', '--show-toplevel'], cwd) || null; } catch { /* not a repo */ }
39
+ try { out.remote = git(['remote', 'get-url', 'origin'], cwd) || null; } catch { /* no origin */ }
40
+ try { out.branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd) || null; } catch { /* detached */ }
41
+ return out;
42
+ }
43
+
44
+ /** Reduce a git remote to a canonical https://host/org/name (mirrors the server). */
45
+ export function canonicalRepo(remote) {
46
+ if (!remote) return null;
47
+ const raw = remote.trim();
48
+ const scp = /^(?:[^@/]+@)?([^/:]+):(.+)$/.exec(raw);
49
+ const url = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(raw);
50
+ let host, path;
51
+ if (url) { host = url[1]; path = url[2]; }
52
+ else if (scp) { host = scp[1]; path = scp[2]; }
53
+ else return null;
54
+ const parts = path.replace(/\.git$/i, '').replace(/\/+$/, '').split('/').filter(Boolean);
55
+ if (parts.length < 2) return null;
56
+ const name = parts.pop().toLowerCase();
57
+ const org = parts.join('/').toLowerCase();
58
+ return `https://${host.toLowerCase()}/${org}/${name}`;
59
+ }
60
+
61
+ // ── repo config (.mnema/config.json — committed, no secrets) ──────────────────
62
+
63
+ export function configPath(root) {
64
+ return join(root || process.cwd(), '.mnema', 'config.json');
65
+ }
66
+
67
+ export function readConfig(root) {
68
+ const p = configPath(root);
69
+ if (!existsSync(p)) return null;
70
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
71
+ }
72
+
73
+ export function writeConfig(root, cfg) {
74
+ const p = configPath(root);
75
+ mkdirSync(dirname(p), { recursive: true });
76
+ writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n');
77
+ return p;
78
+ }
79
+
80
+ export function removeConfigDir(root) {
81
+ const dir = join(root || process.cwd(), '.mnema');
82
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
83
+ }
84
+
85
+ // ── API ──────────────────────────────────────────────────────────────────────
86
+
87
+ export async function apiFetch(origin, path, { token, method = 'GET', body } = {}) {
88
+ const controller = new AbortController();
89
+ const timer = setTimeout(() => controller.abort(), 15000);
90
+ try {
91
+ const res = await fetch(origin + path, {
92
+ method,
93
+ headers: {
94
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
95
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
96
+ },
97
+ body: body ? JSON.stringify(body) : undefined,
98
+ signal: controller.signal,
99
+ });
100
+ const text = await res.text();
101
+ let json;
102
+ try { json = text ? JSON.parse(text) : null; } catch { json = null; }
103
+ return { ok: res.ok, status: res.status, json, text };
104
+ } finally {
105
+ clearTimeout(timer);
106
+ }
107
+ }
108
+
109
+ // ── prompts ────────────────────────────────────────────────────────────────────
110
+
111
+ export function prompt(question) {
112
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
113
+ return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a.trim()); }));
114
+ }
115
+
116
+ /** Prompt without echoing (for secrets). Falls back to visible if not a TTY. */
117
+ export function promptHidden(question) {
118
+ return new Promise((resolve) => {
119
+ const { stdin, stdout } = process;
120
+ if (!stdin.isTTY) {
121
+ // Non-interactive: read one line plainly.
122
+ const rl = createInterface({ input: stdin, output: stdout });
123
+ rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
124
+ return;
125
+ }
126
+ stdout.write(question);
127
+ stdin.setRawMode(true);
128
+ stdin.resume();
129
+ let buf = '';
130
+ const onData = (ch) => {
131
+ const s = ch.toString('utf8');
132
+ if (s === '\r' || s === '\n') {
133
+ stdin.setRawMode(false); stdin.pause(); stdin.removeListener('data', onData);
134
+ stdout.write('\n'); resolve(buf.trim()); return;
135
+ }
136
+ if (s === '') { stdout.write('\n'); process.exit(1); } // Ctrl-C
137
+ if (s === '' || s === '\b') { buf = buf.slice(0, -1); return; } // backspace
138
+ buf += s;
139
+ };
140
+ stdin.on('data', onData);
141
+ });
142
+ }
143
+
144
+ // ── local Claude Code transcripts ──────────────────────────────────────────────
145
+
146
+ /**
147
+ * Returns local Claude Code transcript summaries for a repo. Claude Code stores
148
+ * transcripts under ~/.claude/projects/<encoded-cwd>/<session_id>.jsonl, where the
149
+ * encoded dir contains the cwd path with slashes replaced by dashes — so we match
150
+ * on the repo root path appearing in the encoded directory name.
151
+ */
152
+ export function localSessionsForRepo(root, limit = 10) {
153
+ const base = join(homedir(), '.claude', 'projects');
154
+ if (!root || !existsSync(base)) return [];
155
+ const needle = root.replace(/\//g, '-');
156
+ const out = [];
157
+ let dirs = [];
158
+ try { dirs = readdirSync(base); } catch { return []; }
159
+ for (const d of dirs) {
160
+ if (!d.includes(needle)) continue;
161
+ const full = join(base, d);
162
+ let files = [];
163
+ try { if (!statSync(full).isDirectory()) continue; files = readdirSync(full); } catch { continue; }
164
+ for (const f of files) {
165
+ if (!f.endsWith('.jsonl')) continue;
166
+ try {
167
+ const st = statSync(join(full, f));
168
+ out.push({ sessionId: f.replace(/\.jsonl$/, ''), mtimeMs: st.mtimeMs, sizeBytes: st.size });
169
+ } catch { /* skip */ }
170
+ }
171
+ }
172
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
173
+ return out.slice(0, limit);
174
+ }
175
+
176
+ export { rmSync };