cli-chat-mcp 0.2.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 +133 -0
- package/dist/add-contact.js +123 -0
- package/dist/check-inbox.js +385 -0
- package/dist/init-identity.js +271 -0
- package/dist/install.js +126 -0
- package/dist/migrate.js +221 -0
- package/dist/server-net.js +819 -0
- package/package.json +46 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/init-identity.ts
|
|
4
|
+
import { mkdirSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { userInfo } from "node:os";
|
|
7
|
+
|
|
8
|
+
// src/crypto.ts
|
|
9
|
+
var s = null;
|
|
10
|
+
async function initCrypto() {
|
|
11
|
+
if (s) return;
|
|
12
|
+
const mod = await import("libsodium-wrappers");
|
|
13
|
+
const sodium = mod.default ?? mod;
|
|
14
|
+
await sodium.ready;
|
|
15
|
+
s = sodium;
|
|
16
|
+
}
|
|
17
|
+
function generateIdentity() {
|
|
18
|
+
const box = s.crypto_box_keypair();
|
|
19
|
+
const sign = s.crypto_sign_keypair();
|
|
20
|
+
return {
|
|
21
|
+
boxPub: s.to_hex(box.publicKey),
|
|
22
|
+
boxSec: s.to_hex(box.privateKey),
|
|
23
|
+
signPub: s.to_hex(sign.publicKey),
|
|
24
|
+
signSec: s.to_hex(sign.privateKey)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function signDetached(message, signSecHex) {
|
|
28
|
+
const sig = s.crypto_sign_detached(s.from_string(message), s.from_hex(signSecHex));
|
|
29
|
+
return s.to_hex(sig);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/identity.ts
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
function loadIdentity(path) {
|
|
35
|
+
const id2 = JSON.parse(readFileSync(path, "utf8"));
|
|
36
|
+
for (const k of ["boxPub", "boxSec", "signPub", "signSec"]) {
|
|
37
|
+
if (!id2[k]) throw new Error(`identity at ${path} missing ${k}`);
|
|
38
|
+
}
|
|
39
|
+
return id2;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/canonical.ts
|
|
43
|
+
var MAX_SKEW_MS = 5 * 60 * 1e3;
|
|
44
|
+
function canonical(method, path, timestamp, body) {
|
|
45
|
+
return `${method.toUpperCase()}
|
|
46
|
+
${path}
|
|
47
|
+
${timestamp}
|
|
48
|
+
${body}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/auth.ts
|
|
52
|
+
function makeAuthHeaders(signPubHex, signSecHex, method, path, body, now) {
|
|
53
|
+
const sig = signDetached(canonical(method, path, now, body), signSecHex);
|
|
54
|
+
return {
|
|
55
|
+
"x-pubkey": signPubHex,
|
|
56
|
+
"x-timestamp": String(now),
|
|
57
|
+
"x-signature": sig
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/mailbox-client.ts
|
|
62
|
+
function createMailboxClient(baseUrl, identity, now) {
|
|
63
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
64
|
+
function headers(method, path, body) {
|
|
65
|
+
return makeAuthHeaders(
|
|
66
|
+
identity.signPub,
|
|
67
|
+
identity.signSec,
|
|
68
|
+
method,
|
|
69
|
+
path,
|
|
70
|
+
body,
|
|
71
|
+
now()
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
async function fail(res, what) {
|
|
75
|
+
let detail = "";
|
|
76
|
+
try {
|
|
77
|
+
detail = JSON.stringify(await res.json());
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`${what} failed: ${res.status} ${detail}`);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
async send(msg) {
|
|
84
|
+
const body = JSON.stringify(msg);
|
|
85
|
+
const res = await fetch(`${base}/messages`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "content-type": "application/json", ...headers("POST", "/messages", body) },
|
|
88
|
+
body
|
|
89
|
+
});
|
|
90
|
+
if (!res.ok) await fail(res, "send");
|
|
91
|
+
},
|
|
92
|
+
async summary() {
|
|
93
|
+
const res = await fetch(`${base}/mailbox`, {
|
|
94
|
+
headers: headers("GET", "/mailbox", "")
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok) await fail(res, "summary");
|
|
97
|
+
return await res.json();
|
|
98
|
+
},
|
|
99
|
+
async drain() {
|
|
100
|
+
const res = await fetch(`${base}/messages`, {
|
|
101
|
+
headers: headers("GET", "/messages", "")
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) await fail(res, "drain");
|
|
104
|
+
const data = await res.json();
|
|
105
|
+
return data.messages;
|
|
106
|
+
},
|
|
107
|
+
async registerHandle(handle) {
|
|
108
|
+
const body = JSON.stringify({ handle, signPub: identity.signPub, boxPub: identity.boxPub });
|
|
109
|
+
const res = await fetch(`${base}/register`, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { "content-type": "application/json", ...headers("POST", "/register", body) },
|
|
112
|
+
body
|
|
113
|
+
});
|
|
114
|
+
if (res.status === 409) return "taken";
|
|
115
|
+
if (!res.ok) await fail(res, "register");
|
|
116
|
+
return "ok";
|
|
117
|
+
},
|
|
118
|
+
async resolveHandle(handle) {
|
|
119
|
+
const res = await fetch(`${base}/resolve/${encodeURIComponent(handle)}`);
|
|
120
|
+
if (res.status === 404) return null;
|
|
121
|
+
if (!res.ok) await fail(res, "resolve");
|
|
122
|
+
return await res.json();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/key-code.ts
|
|
128
|
+
var ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
129
|
+
var HANDLE_LEN = 6;
|
|
130
|
+
function randomHandle(randomBytes2) {
|
|
131
|
+
let h = "";
|
|
132
|
+
for (let i = 0; i < HANDLE_LEN; i++) h += ALPHABET[randomBytes2[i] % 62];
|
|
133
|
+
return h;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/current-user.ts
|
|
137
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync, writeFileSync } from "node:fs";
|
|
138
|
+
import { join as join2 } from "node:path";
|
|
139
|
+
|
|
140
|
+
// src/paths.ts
|
|
141
|
+
import { existsSync } from "node:fs";
|
|
142
|
+
import { homedir } from "node:os";
|
|
143
|
+
import { join, resolve } from "node:path";
|
|
144
|
+
var codeRoot = resolve(import.meta.dirname, "..");
|
|
145
|
+
function dataHome() {
|
|
146
|
+
const env = process.env.MESSENGER_HOME?.trim();
|
|
147
|
+
if (env) return env;
|
|
148
|
+
if (existsSync(join(codeRoot, "users"))) return codeRoot;
|
|
149
|
+
return join(homedir(), ".cli-chat");
|
|
150
|
+
}
|
|
151
|
+
function usersDir() {
|
|
152
|
+
return join(dataHome(), "users");
|
|
153
|
+
}
|
|
154
|
+
function userDir(user) {
|
|
155
|
+
return join(usersDir(), user);
|
|
156
|
+
}
|
|
157
|
+
function identityFile(user) {
|
|
158
|
+
return join(userDir(user), "identity.json");
|
|
159
|
+
}
|
|
160
|
+
function contactsFile(user) {
|
|
161
|
+
return join(userDir(user), "contacts.json");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/current-user.ts
|
|
165
|
+
var pointerPath = () => join2(usersDir(), ".current");
|
|
166
|
+
function identityDirs() {
|
|
167
|
+
const dir = usersDir();
|
|
168
|
+
if (!existsSync2(dir)) return [];
|
|
169
|
+
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).filter((name) => existsSync2(identityFile(name)));
|
|
170
|
+
}
|
|
171
|
+
function readMeta(dir) {
|
|
172
|
+
try {
|
|
173
|
+
return JSON.parse(readFileSync2(identityFile(dir), "utf8"));
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function resolveDir(sel) {
|
|
179
|
+
const dirs = identityDirs();
|
|
180
|
+
if (dirs.includes(sel)) return sel;
|
|
181
|
+
const low = sel.toLowerCase();
|
|
182
|
+
for (const d of dirs) {
|
|
183
|
+
const id2 = readMeta(d);
|
|
184
|
+
if (!id2) continue;
|
|
185
|
+
if (id2.handle === sel || id2.signPub === sel) return d;
|
|
186
|
+
if (id2.name && id2.name.toLowerCase() === low) return d;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
function currentUser() {
|
|
191
|
+
const env = process.env.MESSENGER_USER?.trim();
|
|
192
|
+
if (env) return resolveDir(env) ?? env;
|
|
193
|
+
const pointer = pointerPath();
|
|
194
|
+
if (existsSync2(pointer)) {
|
|
195
|
+
const val = readFileSync2(pointer, "utf8").trim();
|
|
196
|
+
if (val) return resolveDir(val) ?? val;
|
|
197
|
+
}
|
|
198
|
+
const dirs = identityDirs();
|
|
199
|
+
if (dirs.length === 1) return dirs[0];
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
function setCurrentUser(key) {
|
|
203
|
+
writeFileSync(pointerPath(), key + "\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/config.ts
|
|
207
|
+
var DEFAULT_MAILBOX_URL = "https://mailbox.cli-chat-mcp.workers.dev";
|
|
208
|
+
function resolveMailboxUrl() {
|
|
209
|
+
return process.env.MESSENGER_MAILBOX_URL?.trim() || DEFAULT_MAILBOX_URL;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/init-identity.ts
|
|
213
|
+
var display = (process.env.MESSENGER_USER || process.argv[2] || userInfo().username).trim();
|
|
214
|
+
if (!display) {
|
|
215
|
+
console.error("Couldn't determine a name. Pass one: node src/init-identity.ts <name>");
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
var url = resolveMailboxUrl();
|
|
219
|
+
await initCrypto();
|
|
220
|
+
mkdirSync(usersDir(), { recursive: true });
|
|
221
|
+
var existingDir = currentUser();
|
|
222
|
+
var id;
|
|
223
|
+
var existing = false;
|
|
224
|
+
if (existingDir) {
|
|
225
|
+
try {
|
|
226
|
+
id = loadIdentity(identityFile(existingDir));
|
|
227
|
+
existing = true;
|
|
228
|
+
console.error(`Using your existing identity (${id.name ?? existingDir}).`);
|
|
229
|
+
} catch {
|
|
230
|
+
id = generateIdentity();
|
|
231
|
+
id.name = display;
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
id = generateIdentity();
|
|
235
|
+
id.name = display;
|
|
236
|
+
}
|
|
237
|
+
var client = createMailboxClient(url, id, () => Date.now());
|
|
238
|
+
try {
|
|
239
|
+
if (!id.handle) {
|
|
240
|
+
let claimed = null;
|
|
241
|
+
for (let i = 0; i < 8 && !claimed; i++) {
|
|
242
|
+
const candidate = randomHandle(randomBytes(8));
|
|
243
|
+
if (await client.registerHandle(candidate) === "ok") claimed = candidate;
|
|
244
|
+
}
|
|
245
|
+
if (!claimed) throw new Error("couldn't find a free handle after several tries");
|
|
246
|
+
id.handle = claimed;
|
|
247
|
+
} else {
|
|
248
|
+
await client.registerHandle(id.handle);
|
|
249
|
+
}
|
|
250
|
+
mkdirSync(userDir(id.handle), { recursive: true });
|
|
251
|
+
writeFileSync2(identityFile(id.handle), JSON.stringify(id, null, 2) + "\n");
|
|
252
|
+
if (!existing) {
|
|
253
|
+
writeFileSync2(
|
|
254
|
+
contactsFile(id.handle),
|
|
255
|
+
JSON.stringify({ me: id.signPub, contacts: [] }, null, 2) + "\n"
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
setCurrentUser(id.handle);
|
|
259
|
+
console.error("\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
260
|
+
console.error(`Your code \u2014 give it to anyone who wants to message you. They say:`);
|
|
261
|
+
console.error(` write ${display} at <this code>: hi
|
|
262
|
+
`);
|
|
263
|
+
console.log(id.handle);
|
|
264
|
+
console.error("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
265
|
+
} catch (e) {
|
|
266
|
+
console.error(`
|
|
267
|
+
Couldn't reach the registry (${e.message}).`);
|
|
268
|
+
console.error("No handle claimed \u2014 re-run this once you're online. (A handle is");
|
|
269
|
+
console.error("required: it's the key your identity is stored under.)");
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/install.ts
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
// src/config.ts
|
|
10
|
+
var DEFAULT_MAILBOX_URL = "https://mailbox.cli-chat-mcp.workers.dev";
|
|
11
|
+
function resolveMailboxUrl() {
|
|
12
|
+
return process.env.MESSENGER_MAILBOX_URL?.trim() || DEFAULT_MAILBOX_URL;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/install.ts
|
|
16
|
+
var HOME = process.env.HOME_OVERRIDE ?? homedir();
|
|
17
|
+
var PKG = "cli-chat-mcp";
|
|
18
|
+
var NAME = "cli-chat";
|
|
19
|
+
var user = process.env.MESSENGER_USER;
|
|
20
|
+
var mailboxUrl = resolveMailboxUrl();
|
|
21
|
+
var env = { MESSENGER_MAILBOX_URL: mailboxUrl };
|
|
22
|
+
if (user) env.MESSENGER_USER = user;
|
|
23
|
+
var spec = {
|
|
24
|
+
command: "npx",
|
|
25
|
+
args: ["-y", PKG],
|
|
26
|
+
env
|
|
27
|
+
};
|
|
28
|
+
var results = [];
|
|
29
|
+
var ok = (cli, detail) => results.push(`\u2713 ${cli}: ${detail}`);
|
|
30
|
+
var skip = (cli, detail) => results.push(`\u2013 ${cli}: ${detail}`);
|
|
31
|
+
function onPath(bin) {
|
|
32
|
+
try {
|
|
33
|
+
execSync(`command -v ${bin}`, { stdio: "ignore" });
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function mergeJsonMcp(path) {
|
|
40
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
41
|
+
let cfg = {};
|
|
42
|
+
if (existsSync(path)) {
|
|
43
|
+
try {
|
|
44
|
+
cfg = JSON.parse(readFileSync(path, "utf8"));
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error(`existing ${path} is not valid JSON \u2014 left untouched`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
cfg.mcpServers = cfg.mcpServers ?? {};
|
|
50
|
+
cfg.mcpServers[NAME] = spec;
|
|
51
|
+
writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n");
|
|
52
|
+
}
|
|
53
|
+
if (onPath("claude")) {
|
|
54
|
+
try {
|
|
55
|
+
const envFlags = `--env MESSENGER_MAILBOX_URL=${mailboxUrl}` + (user ? ` --env MESSENGER_USER=${user}` : "");
|
|
56
|
+
execSync(
|
|
57
|
+
`claude mcp add ${NAME} --scope user ${envFlags} -- npx -y ${PKG}`,
|
|
58
|
+
{ stdio: "ignore" }
|
|
59
|
+
);
|
|
60
|
+
ok("Claude Code", "registered (user scope) via `claude mcp add`");
|
|
61
|
+
} catch {
|
|
62
|
+
skip("Claude Code", "`claude mcp add` failed; add the manual block below");
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
skip("Claude Code", "`claude` not on PATH; add the manual block below");
|
|
66
|
+
}
|
|
67
|
+
var geminiDir = join(HOME, ".gemini");
|
|
68
|
+
if (existsSync(geminiDir) || onPath("gemini")) {
|
|
69
|
+
try {
|
|
70
|
+
mergeJsonMcp(join(geminiDir, "settings.json"));
|
|
71
|
+
ok("Gemini CLI", "wrote ~/.gemini/settings.json");
|
|
72
|
+
} catch (e) {
|
|
73
|
+
skip("Gemini CLI", e.message);
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
skip("Gemini CLI", "not detected");
|
|
77
|
+
}
|
|
78
|
+
var cursorDir = join(HOME, ".cursor");
|
|
79
|
+
if (existsSync(cursorDir) || onPath("cursor")) {
|
|
80
|
+
try {
|
|
81
|
+
mergeJsonMcp(join(cursorDir, "mcp.json"));
|
|
82
|
+
ok("Cursor", "wrote ~/.cursor/mcp.json");
|
|
83
|
+
} catch (e) {
|
|
84
|
+
skip("Cursor", e.message);
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
skip("Cursor", "not detected");
|
|
88
|
+
}
|
|
89
|
+
var codexDir = join(HOME, ".codex");
|
|
90
|
+
if (existsSync(codexDir) || onPath("codex")) {
|
|
91
|
+
try {
|
|
92
|
+
const tomlPath = join(codexDir, "config.toml");
|
|
93
|
+
mkdirSync(codexDir, { recursive: true });
|
|
94
|
+
const envToml = user ? `{ MESSENGER_USER = ${JSON.stringify(user)}, MESSENGER_MAILBOX_URL = ${JSON.stringify(mailboxUrl)} }` : `{ MESSENGER_MAILBOX_URL = ${JSON.stringify(mailboxUrl)} }`;
|
|
95
|
+
const block = `
|
|
96
|
+
[mcp_servers.${NAME}]
|
|
97
|
+
command = "npx"
|
|
98
|
+
args = ["-y", ${JSON.stringify(PKG)}]
|
|
99
|
+
env = ${envToml}
|
|
100
|
+
`;
|
|
101
|
+
const existing = existsSync(tomlPath) ? readFileSync(tomlPath, "utf8") : "";
|
|
102
|
+
if (existing.includes(`[mcp_servers.${NAME}]`)) {
|
|
103
|
+
skip("Codex CLI", "already present in ~/.codex/config.toml (left as-is)");
|
|
104
|
+
} else {
|
|
105
|
+
writeFileSync(tomlPath, existing + block);
|
|
106
|
+
ok("Codex CLI", "appended to ~/.codex/config.toml");
|
|
107
|
+
}
|
|
108
|
+
} catch (e) {
|
|
109
|
+
skip("Codex CLI", e.message);
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
skip("Codex CLI", "not detected");
|
|
113
|
+
}
|
|
114
|
+
console.log(`
|
|
115
|
+
cli-chat install \u2014 package ${PKG}, mailbox ${mailboxUrl}
|
|
116
|
+
`);
|
|
117
|
+
console.log(results.join("\n"));
|
|
118
|
+
console.log(
|
|
119
|
+
`
|
|
120
|
+
Manual config (any MCP-capable CLI) \u2014 register a stdio server:
|
|
121
|
+
command: npx
|
|
122
|
+
args: ["-y", "${PKG}"]
|
|
123
|
+
env: MESSENGER_MAILBOX_URL=${mailboxUrl}` + (user ? `, MESSENGER_USER=${user}` : "") + `
|
|
124
|
+
|
|
125
|
+
Restart each CLI to pick it up, then say "set me up" to get your code.`
|
|
126
|
+
);
|
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/migrate.ts
|
|
4
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { join as join2 } from "node:path";
|
|
7
|
+
|
|
8
|
+
// src/crypto.ts
|
|
9
|
+
var s = null;
|
|
10
|
+
async function initCrypto() {
|
|
11
|
+
if (s) return;
|
|
12
|
+
const mod = await import("libsodium-wrappers");
|
|
13
|
+
const sodium = mod.default ?? mod;
|
|
14
|
+
await sodium.ready;
|
|
15
|
+
s = sodium;
|
|
16
|
+
}
|
|
17
|
+
function signDetached(message, signSecHex) {
|
|
18
|
+
const sig = s.crypto_sign_detached(s.from_string(message), s.from_hex(signSecHex));
|
|
19
|
+
return s.to_hex(sig);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/identity.ts
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
function loadIdentity(path) {
|
|
25
|
+
const id = JSON.parse(readFileSync(path, "utf8"));
|
|
26
|
+
for (const k of ["boxPub", "boxSec", "signPub", "signSec"]) {
|
|
27
|
+
if (!id[k]) throw new Error(`identity at ${path} missing ${k}`);
|
|
28
|
+
}
|
|
29
|
+
return id;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/canonical.ts
|
|
33
|
+
var MAX_SKEW_MS = 5 * 60 * 1e3;
|
|
34
|
+
function canonical(method, path, timestamp, body) {
|
|
35
|
+
return `${method.toUpperCase()}
|
|
36
|
+
${path}
|
|
37
|
+
${timestamp}
|
|
38
|
+
${body}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/auth.ts
|
|
42
|
+
function makeAuthHeaders(signPubHex, signSecHex, method, path, body, now) {
|
|
43
|
+
const sig = signDetached(canonical(method, path, now, body), signSecHex);
|
|
44
|
+
return {
|
|
45
|
+
"x-pubkey": signPubHex,
|
|
46
|
+
"x-timestamp": String(now),
|
|
47
|
+
"x-signature": sig
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/mailbox-client.ts
|
|
52
|
+
function createMailboxClient(baseUrl, identity, now) {
|
|
53
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
54
|
+
function headers(method, path, body) {
|
|
55
|
+
return makeAuthHeaders(
|
|
56
|
+
identity.signPub,
|
|
57
|
+
identity.signSec,
|
|
58
|
+
method,
|
|
59
|
+
path,
|
|
60
|
+
body,
|
|
61
|
+
now()
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
async function fail(res, what) {
|
|
65
|
+
let detail = "";
|
|
66
|
+
try {
|
|
67
|
+
detail = JSON.stringify(await res.json());
|
|
68
|
+
} catch {
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`${what} failed: ${res.status} ${detail}`);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
async send(msg) {
|
|
74
|
+
const body = JSON.stringify(msg);
|
|
75
|
+
const res = await fetch(`${base}/messages`, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "content-type": "application/json", ...headers("POST", "/messages", body) },
|
|
78
|
+
body
|
|
79
|
+
});
|
|
80
|
+
if (!res.ok) await fail(res, "send");
|
|
81
|
+
},
|
|
82
|
+
async summary() {
|
|
83
|
+
const res = await fetch(`${base}/mailbox`, {
|
|
84
|
+
headers: headers("GET", "/mailbox", "")
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) await fail(res, "summary");
|
|
87
|
+
return await res.json();
|
|
88
|
+
},
|
|
89
|
+
async drain() {
|
|
90
|
+
const res = await fetch(`${base}/messages`, {
|
|
91
|
+
headers: headers("GET", "/messages", "")
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) await fail(res, "drain");
|
|
94
|
+
const data = await res.json();
|
|
95
|
+
return data.messages;
|
|
96
|
+
},
|
|
97
|
+
async registerHandle(handle) {
|
|
98
|
+
const body = JSON.stringify({ handle, signPub: identity.signPub, boxPub: identity.boxPub });
|
|
99
|
+
const res = await fetch(`${base}/register`, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "content-type": "application/json", ...headers("POST", "/register", body) },
|
|
102
|
+
body
|
|
103
|
+
});
|
|
104
|
+
if (res.status === 409) return "taken";
|
|
105
|
+
if (!res.ok) await fail(res, "register");
|
|
106
|
+
return "ok";
|
|
107
|
+
},
|
|
108
|
+
async resolveHandle(handle) {
|
|
109
|
+
const res = await fetch(`${base}/resolve/${encodeURIComponent(handle)}`);
|
|
110
|
+
if (res.status === 404) return null;
|
|
111
|
+
if (!res.ok) await fail(res, "resolve");
|
|
112
|
+
return await res.json();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/key-code.ts
|
|
118
|
+
var ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
119
|
+
var HANDLE_LEN = 6;
|
|
120
|
+
function randomHandle(randomBytes2) {
|
|
121
|
+
let h = "";
|
|
122
|
+
for (let i = 0; i < HANDLE_LEN; i++) h += ALPHABET[randomBytes2[i] % 62];
|
|
123
|
+
return h;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/paths.ts
|
|
127
|
+
import { existsSync } from "node:fs";
|
|
128
|
+
import { homedir } from "node:os";
|
|
129
|
+
import { join, resolve } from "node:path";
|
|
130
|
+
var codeRoot = resolve(import.meta.dirname, "..");
|
|
131
|
+
function dataHome() {
|
|
132
|
+
const env = process.env.MESSENGER_HOME?.trim();
|
|
133
|
+
if (env) return env;
|
|
134
|
+
if (existsSync(join(codeRoot, "users"))) return codeRoot;
|
|
135
|
+
return join(homedir(), ".cli-chat");
|
|
136
|
+
}
|
|
137
|
+
function usersDir() {
|
|
138
|
+
return join(dataHome(), "users");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/config.ts
|
|
142
|
+
var DEFAULT_MAILBOX_URL = "https://mailbox.cli-chat-mcp.workers.dev";
|
|
143
|
+
function resolveMailboxUrl() {
|
|
144
|
+
return process.env.MESSENGER_MAILBOX_URL?.trim() || DEFAULT_MAILBOX_URL;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// src/migrate.ts
|
|
148
|
+
var usersDir2 = usersDir();
|
|
149
|
+
var url = resolveMailboxUrl();
|
|
150
|
+
await initCrypto();
|
|
151
|
+
if (!existsSync2(usersDir2)) {
|
|
152
|
+
console.log("No users/ directory \u2014 nothing to migrate.");
|
|
153
|
+
process.exit(0);
|
|
154
|
+
}
|
|
155
|
+
var pointerPath = join2(usersDir2, ".current");
|
|
156
|
+
var currentBefore = existsSync2(pointerPath) ? readFileSync2(pointerPath, "utf8").trim() : null;
|
|
157
|
+
var dirs = readdirSync(usersDir2, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).filter((name) => existsSync2(join2(usersDir2, name, "identity.json")));
|
|
158
|
+
var remap = /* @__PURE__ */ new Map();
|
|
159
|
+
for (const dir of dirs) {
|
|
160
|
+
const idPath = join2(usersDir2, dir, "identity.json");
|
|
161
|
+
let id;
|
|
162
|
+
try {
|
|
163
|
+
id = loadIdentity(idPath);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
console.error(`! ${dir}: can't load identity (${e.message}) \u2014 skipping.`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
let dirty = false;
|
|
169
|
+
if (!id.name) {
|
|
170
|
+
id.name = dir;
|
|
171
|
+
dirty = true;
|
|
172
|
+
}
|
|
173
|
+
if (!id.handle) {
|
|
174
|
+
const client = createMailboxClient(url, id, () => Date.now());
|
|
175
|
+
let claimed = null;
|
|
176
|
+
for (let i = 0; i < 8 && !claimed; i++) {
|
|
177
|
+
const candidate = randomHandle(randomBytes(8));
|
|
178
|
+
if (await client.registerHandle(candidate) === "ok") claimed = candidate;
|
|
179
|
+
}
|
|
180
|
+
if (!claimed) {
|
|
181
|
+
console.error(`! ${dir}: couldn't claim a handle \u2014 leaving as-is.`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
id.handle = claimed;
|
|
185
|
+
dirty = true;
|
|
186
|
+
console.log(`+ ${dir}: claimed handle ${id.handle}`);
|
|
187
|
+
}
|
|
188
|
+
if (dirty) writeFileSync(idPath, JSON.stringify(id, null, 2) + "\n");
|
|
189
|
+
remap.set(dir, id.handle);
|
|
190
|
+
if (dir === id.handle) {
|
|
191
|
+
console.log(`= ${dir}: already keyed by handle.`);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const target = join2(usersDir2, id.handle);
|
|
195
|
+
if (existsSync2(target)) {
|
|
196
|
+
console.error(`! ${dir} -> ${id.handle}: target already exists \u2014 skipping rename.`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
renameSync(join2(usersDir2, dir), target);
|
|
200
|
+
console.log(`\u2192 ${dir} -> ${id.handle} (name "${id.name}")`);
|
|
201
|
+
}
|
|
202
|
+
if (currentBefore) {
|
|
203
|
+
let newCurrent = null;
|
|
204
|
+
if (remap.has(currentBefore)) {
|
|
205
|
+
newCurrent = remap.get(currentBefore);
|
|
206
|
+
} else {
|
|
207
|
+
const low = currentBefore.toLowerCase();
|
|
208
|
+
for (const handle of remap.values()) {
|
|
209
|
+
const id = loadIdentity(join2(usersDir2, handle, "identity.json"));
|
|
210
|
+
if (id.handle === currentBefore || id.name && id.name.toLowerCase() === low) {
|
|
211
|
+
newCurrent = handle;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (newCurrent && newCurrent !== currentBefore) {
|
|
217
|
+
writeFileSync(pointerPath, newCurrent + "\n");
|
|
218
|
+
console.log(`. .current: "${currentBefore}" -> "${newCurrent}"`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
console.log("Migration complete.");
|