@infomity/remogent 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/README.md +225 -3
- package/dist/adapters/claude.js +518 -0
- package/dist/adapters/codex.js +510 -0
- package/dist/adapters/gemini.js +287 -0
- package/dist/adapters/rpc.js +83 -0
- package/dist/adapters/util.js +90 -0
- package/dist/firewall.js +75 -0
- package/dist/identity.js +56 -0
- package/dist/index.js +416 -0
- package/dist/logfile.js +39 -0
- package/dist/port.js +19 -0
- package/dist/protocol.js +142 -0
- package/dist/server.js +170 -0
- package/dist/service.js +244 -0
- package/dist/sessions.js +482 -0
- package/dist/store.js +103 -0
- package/dist/title.js +43 -0
- package/package.json +63 -4
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { RpcError, RpcPeer } from "./rpc.js";
|
|
3
|
+
import { findOnPath, killTree, spawnCli } from "./util.js";
|
|
4
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5
|
+
const str = (v) => (typeof v === "string" && v.length > 0 ? v : undefined);
|
|
6
|
+
/** Cap on tool output stored in a transcript event. */
|
|
7
|
+
const MAX_OUTPUT = 64 * 1024;
|
|
8
|
+
// ---- ACP -> Remogent mapping ----
|
|
9
|
+
/** ACP ToolKind -> protocol risk. */
|
|
10
|
+
function riskOf(kind) {
|
|
11
|
+
switch (kind) {
|
|
12
|
+
case "read":
|
|
13
|
+
case "search":
|
|
14
|
+
return "read";
|
|
15
|
+
case "edit":
|
|
16
|
+
case "delete":
|
|
17
|
+
case "move":
|
|
18
|
+
return "edit";
|
|
19
|
+
case "execute":
|
|
20
|
+
return "shell";
|
|
21
|
+
case "fetch":
|
|
22
|
+
return "network";
|
|
23
|
+
default:
|
|
24
|
+
return "other";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Not a strict unified diff (no hunks); old lines prefixed "-", new lines "+". Enough for a phone review sheet. */
|
|
28
|
+
function simpleDiff(path, oldText, newText) {
|
|
29
|
+
const lines = (v, p) => (typeof v === "string" && v ? v.split("\n").map((l) => p + l) : []);
|
|
30
|
+
return [`--- ${path}`, `+++ ${path}`, ...lines(oldText, "-"), ...lines(newText, "+")].join("\n");
|
|
31
|
+
}
|
|
32
|
+
function extractContent(content) {
|
|
33
|
+
if (!Array.isArray(content))
|
|
34
|
+
return {};
|
|
35
|
+
const texts = [];
|
|
36
|
+
const diffs = [];
|
|
37
|
+
for (const c of content) {
|
|
38
|
+
if (!isObj(c))
|
|
39
|
+
continue;
|
|
40
|
+
if (c["type"] === "content" && isObj(c["content"]) && c["content"]["type"] === "text") {
|
|
41
|
+
const t = str(c["content"]["text"]);
|
|
42
|
+
if (t)
|
|
43
|
+
texts.push(t);
|
|
44
|
+
}
|
|
45
|
+
else if (c["type"] === "diff" && typeof c["path"] === "string") {
|
|
46
|
+
diffs.push(simpleDiff(c["path"], c["oldText"], c["newText"]));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
...(texts.length ? { output: texts.join("\n").slice(0, MAX_OUTPUT) } : {}),
|
|
51
|
+
...(diffs.length ? { diff: diffs.join("\n\n").slice(0, MAX_OUTPUT) } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function textOf(update) {
|
|
55
|
+
const c = update["content"];
|
|
56
|
+
return isObj(c) && c["type"] === "text" ? str(c["text"]) : undefined;
|
|
57
|
+
}
|
|
58
|
+
function toEvent(update) {
|
|
59
|
+
switch (update["sessionUpdate"]) {
|
|
60
|
+
case "agent_message_chunk": {
|
|
61
|
+
const text = textOf(update);
|
|
62
|
+
// Gemini echoes mode changes as message text ("[MODE_UPDATE] yolo"); that is CLI noise, not agent output.
|
|
63
|
+
return text && !/^\[MODE_UPDATE\]/.test(text) ? { kind: "agent_text_delta", text } : undefined;
|
|
64
|
+
}
|
|
65
|
+
case "agent_thought_chunk": {
|
|
66
|
+
const text = textOf(update);
|
|
67
|
+
return text ? { kind: "agent_thought_delta", text } : undefined;
|
|
68
|
+
}
|
|
69
|
+
case "tool_call": {
|
|
70
|
+
const toolCallId = str(update["toolCallId"]);
|
|
71
|
+
if (!toolCallId)
|
|
72
|
+
return undefined;
|
|
73
|
+
const title = str(update["title"]);
|
|
74
|
+
const kind = str(update["kind"]);
|
|
75
|
+
return {
|
|
76
|
+
kind: "tool_call",
|
|
77
|
+
toolCallId,
|
|
78
|
+
name: kind ?? title ?? "tool",
|
|
79
|
+
...(title ? { title } : {}),
|
|
80
|
+
risk: riskOf(kind),
|
|
81
|
+
input: update["rawInput"] ?? {},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
case "tool_call_update": {
|
|
85
|
+
const toolCallId = str(update["toolCallId"]);
|
|
86
|
+
if (!toolCallId)
|
|
87
|
+
return undefined;
|
|
88
|
+
const s = str(update["status"]);
|
|
89
|
+
const status = s === "completed" ? "completed" : s === "failed" ? "failed" : "running";
|
|
90
|
+
return { kind: "tool_call_update", toolCallId, status, ...extractContent(update["content"]) };
|
|
91
|
+
}
|
|
92
|
+
default:
|
|
93
|
+
return undefined; // plan, available_commands_update, current_mode_update, user_message_chunk (history on load)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const MODE_IDS = { ask: "default", plan: "plan", auto_edit: "autoEdit", auto: "yolo" };
|
|
97
|
+
/** Path of the `gemini` executable if it is installed on this host. */
|
|
98
|
+
export function findGemini() {
|
|
99
|
+
return findOnPath("gemini");
|
|
100
|
+
}
|
|
101
|
+
function lastLine(s) {
|
|
102
|
+
const lines = s.trim().split(/\r?\n/);
|
|
103
|
+
return lines[lines.length - 1] ?? "";
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Reads `models` from a `session/new` result: `{ availableModels: [{ modelId, name, description? }], currentModelId }`
|
|
107
|
+
* (verified on Gemini CLI 0.68.0). The model the CLI starts with (`currentModelId`, usually `auto`) is marked as default.
|
|
108
|
+
*/
|
|
109
|
+
function parseGeminiModels(res) {
|
|
110
|
+
const block = isObj(res) && isObj(res["models"]) ? res["models"] : undefined;
|
|
111
|
+
if (!block || !Array.isArray(block["availableModels"]))
|
|
112
|
+
return [];
|
|
113
|
+
const current = str(block["currentModelId"]);
|
|
114
|
+
return block["availableModels"].filter(isObj).flatMap((x) => {
|
|
115
|
+
const id = str(x["modelId"]);
|
|
116
|
+
if (!id)
|
|
117
|
+
return [];
|
|
118
|
+
const description = str(x["description"]);
|
|
119
|
+
return [{ id, name: str(x["name"]) ?? id, ...(description ? { description } : {}), ...(id === current ? { isDefault: true } : {}) }];
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Asks a short-lived `gemini --acp` process for its model list (initialize + session/new; no model call is made). */
|
|
123
|
+
async function queryGeminiModels(binary) {
|
|
124
|
+
const child = spawnCli(binary, ["--acp"], homedir());
|
|
125
|
+
const rpc = new RpcPeer(child);
|
|
126
|
+
let stderrTail = "";
|
|
127
|
+
child.stderr.on("data", (d) => {
|
|
128
|
+
stderrTail = (stderrTail + d.toString()).slice(-2000);
|
|
129
|
+
});
|
|
130
|
+
child.on("error", (e) => rpc.failAll(new Error(`gemini failed to start: ${e.message}`)));
|
|
131
|
+
child.on("exit", (code) => rpc.failAll(new Error(stderrTail ? `gemini exited (code ${code}): ${lastLine(stderrTail)}` : `gemini exited (code ${code})`)));
|
|
132
|
+
let timer;
|
|
133
|
+
try {
|
|
134
|
+
const work = (async () => {
|
|
135
|
+
await rpc.request("initialize", { protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } } });
|
|
136
|
+
return parseGeminiModels(await rpc.request("session/new", { cwd: homedir(), mcpServers: [] }));
|
|
137
|
+
})();
|
|
138
|
+
const timeout = new Promise((_, reject) => {
|
|
139
|
+
timer = setTimeout(() => reject(new Error("gemini did not answer in time")), 20_000);
|
|
140
|
+
});
|
|
141
|
+
return await Promise.race([work, timeout]);
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
if (timer)
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
killTree(child);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export function createGeminiAdapter(binary) {
|
|
150
|
+
return {
|
|
151
|
+
engine: "gemini",
|
|
152
|
+
listModels: () => queryGeminiModels(binary),
|
|
153
|
+
async start({ cwd, permissionMode, model }, sink) {
|
|
154
|
+
const child = spawnCli(binary, ["--acp"], cwd);
|
|
155
|
+
const rpc = new RpcPeer(child);
|
|
156
|
+
let closed = false; // set once the session is intentionally closed or has died
|
|
157
|
+
let inTurn = false; // a prompt request is in flight (its rejection reports process death)
|
|
158
|
+
let stderrTail = "";
|
|
159
|
+
let sessionId;
|
|
160
|
+
child.stderr.on("data", (d) => {
|
|
161
|
+
stderrTail = (stderrTail + d.toString()).slice(-2000); // kept only for exit diagnostics; never logged
|
|
162
|
+
});
|
|
163
|
+
const died = (why) => {
|
|
164
|
+
const err = new Error(stderrTail ? `${why}: ${lastLine(stderrTail)}` : why);
|
|
165
|
+
rpc.failAll(err);
|
|
166
|
+
if (closed)
|
|
167
|
+
return;
|
|
168
|
+
closed = true;
|
|
169
|
+
if (!inTurn)
|
|
170
|
+
sink.emit({ kind: "error", message: err.message });
|
|
171
|
+
sink.emit({ kind: "status", status: "error" });
|
|
172
|
+
};
|
|
173
|
+
child.on("exit", (code) => died(`gemini exited (code ${code})`));
|
|
174
|
+
child.on("error", (e) => died(`gemini failed to start: ${e.message}`));
|
|
175
|
+
rpc.onNotification = (method, params) => {
|
|
176
|
+
if (method !== "session/update" || !isObj(params) || !isObj(params["update"]))
|
|
177
|
+
return;
|
|
178
|
+
if (sessionId && params["sessionId"] !== sessionId)
|
|
179
|
+
return;
|
|
180
|
+
const ev = toEvent(params["update"]);
|
|
181
|
+
if (ev)
|
|
182
|
+
sink.emit(ev);
|
|
183
|
+
};
|
|
184
|
+
rpc.onRequest = async (method, params) => {
|
|
185
|
+
if (method !== "session/request_permission" || !isObj(params)) {
|
|
186
|
+
throw new RpcError(-32601, `unsupported method: ${method}`);
|
|
187
|
+
}
|
|
188
|
+
const tc = isObj(params["toolCall"]) ? params["toolCall"] : {};
|
|
189
|
+
const options = Array.isArray(params["options"]) ? params["options"].filter(isObj) : [];
|
|
190
|
+
const kind = str(tc["kind"]);
|
|
191
|
+
const title = str(tc["title"]);
|
|
192
|
+
const toolCallId = str(tc["toolCallId"]);
|
|
193
|
+
const { diff } = extractContent(tc["content"]);
|
|
194
|
+
// `tool` is the ACP kind (not the title) so a host-side allow_session covers the tool class, not one exact command.
|
|
195
|
+
const decision = await sink.requestPermission({
|
|
196
|
+
...(toolCallId ? { toolCallId } : {}),
|
|
197
|
+
tool: kind ?? "tool",
|
|
198
|
+
risk: riskOf(kind),
|
|
199
|
+
summary: title ?? kind ?? "tool call",
|
|
200
|
+
input: tc["rawInput"] ?? {},
|
|
201
|
+
...(diff ? { diff } : {}),
|
|
202
|
+
});
|
|
203
|
+
const pick = (...kinds) => {
|
|
204
|
+
for (const k of kinds) {
|
|
205
|
+
const o = options.find((x) => x["kind"] === k);
|
|
206
|
+
if (o && typeof o["optionId"] === "string")
|
|
207
|
+
return o["optionId"];
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
};
|
|
211
|
+
// allow_session is tracked by the host; toward Gemini it is a plain one-time allow, so Gemini never
|
|
212
|
+
// persists an "always" trust rule on disk.
|
|
213
|
+
const optionId = decision === "deny" ? pick("reject_once", "reject_always") : pick("allow_once", "allow_always");
|
|
214
|
+
return { outcome: optionId ? { outcome: "selected", optionId } : { outcome: "cancelled" } };
|
|
215
|
+
};
|
|
216
|
+
try {
|
|
217
|
+
await rpc.request("initialize", {
|
|
218
|
+
protocolVersion: 1,
|
|
219
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
|
|
220
|
+
});
|
|
221
|
+
const res = await rpc.request("session/new", { cwd, mcpServers: [] });
|
|
222
|
+
sessionId = isObj(res) ? str(res["sessionId"]) : undefined;
|
|
223
|
+
if (!sessionId)
|
|
224
|
+
throw new Error("gemini did not return a sessionId");
|
|
225
|
+
// set_model accepts any id without complaint (host validated it against the listed models); skip it when the
|
|
226
|
+
// CLI already runs that model.
|
|
227
|
+
const startModel = isObj(res) && isObj(res["models"]) ? str(res["models"]["currentModelId"]) : undefined;
|
|
228
|
+
if (model && model !== startModel)
|
|
229
|
+
await rpc.request("session/set_model", { sessionId, modelId: model });
|
|
230
|
+
if (permissionMode !== "ask") {
|
|
231
|
+
await rpc.request("session/set_mode", { sessionId, modeId: MODE_IDS[permissionMode] });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch (e) {
|
|
235
|
+
closed = true;
|
|
236
|
+
killTree(child);
|
|
237
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
238
|
+
throw new Error(/auth/i.test(msg) ? `${msg} (run \`gemini\` once on the host to sign in)` : msg);
|
|
239
|
+
}
|
|
240
|
+
const sid = sessionId;
|
|
241
|
+
return {
|
|
242
|
+
async prompt(text) {
|
|
243
|
+
if (closed)
|
|
244
|
+
throw new Error("gemini process is not running");
|
|
245
|
+
inTurn = true;
|
|
246
|
+
try {
|
|
247
|
+
const res = await rpc.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text }] });
|
|
248
|
+
const stop = isObj(res) ? str(res["stopReason"]) : undefined;
|
|
249
|
+
const usage = isObj(res) ? res["_meta"] : undefined;
|
|
250
|
+
if (stop === "refusal")
|
|
251
|
+
sink.emit({ kind: "error", message: "the model refused this request" });
|
|
252
|
+
sink.emit({
|
|
253
|
+
kind: "turn_end",
|
|
254
|
+
stopReason: stop === "cancelled"
|
|
255
|
+
? "cancelled"
|
|
256
|
+
: stop === "max_tokens" || stop === "max_turn_requests"
|
|
257
|
+
? "max_budget"
|
|
258
|
+
: stop === "refusal"
|
|
259
|
+
? "error"
|
|
260
|
+
: "end_turn",
|
|
261
|
+
...(usage !== undefined ? { usage } : {}),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
inTurn = false;
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
cancel() {
|
|
269
|
+
if (!closed)
|
|
270
|
+
rpc.notify("session/cancel", { sessionId: sid });
|
|
271
|
+
},
|
|
272
|
+
async setModel(next) {
|
|
273
|
+
if (closed)
|
|
274
|
+
throw new Error("gemini process is not running");
|
|
275
|
+
if (inTurn)
|
|
276
|
+
throw new Error("a turn is already running");
|
|
277
|
+
await rpc.request("session/set_model", { sessionId: sid, modelId: next });
|
|
278
|
+
},
|
|
279
|
+
close() {
|
|
280
|
+
closed = true;
|
|
281
|
+
killTree(child);
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
//# sourceMappingURL=gemini.js.map
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3
|
+
export class RpcError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class RpcPeer {
|
|
11
|
+
child;
|
|
12
|
+
nextId = 1;
|
|
13
|
+
pending = new Map();
|
|
14
|
+
versionField;
|
|
15
|
+
onNotification = () => { };
|
|
16
|
+
onRequest = async (m) => {
|
|
17
|
+
throw new RpcError(-32601, `method not found: ${m}`);
|
|
18
|
+
};
|
|
19
|
+
constructor(child, opts = {}) {
|
|
20
|
+
this.child = child;
|
|
21
|
+
this.versionField = opts.jsonrpcField ?? true;
|
|
22
|
+
child.stdin.on("error", () => { });
|
|
23
|
+
createInterface({ input: child.stdout }).on("line", (line) => this.handleLine(line));
|
|
24
|
+
}
|
|
25
|
+
request(method, params) {
|
|
26
|
+
const id = this.nextId++;
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
this.pending.set(id, { resolve, reject });
|
|
29
|
+
this.write({ id, method, params });
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
notify(method, params) {
|
|
33
|
+
this.write(params === undefined ? { method } : { method, params });
|
|
34
|
+
}
|
|
35
|
+
failAll(err) {
|
|
36
|
+
for (const p of this.pending.values())
|
|
37
|
+
p.reject(err);
|
|
38
|
+
this.pending.clear();
|
|
39
|
+
}
|
|
40
|
+
write(msg) {
|
|
41
|
+
if (this.child.stdin.writable)
|
|
42
|
+
this.child.stdin.write(JSON.stringify(this.versionField ? { jsonrpc: "2.0", ...msg } : msg) + "\n");
|
|
43
|
+
}
|
|
44
|
+
handleLine(line) {
|
|
45
|
+
const t = line.trim();
|
|
46
|
+
if (!t.startsWith("{"))
|
|
47
|
+
return; // ignore any non-JSON noise on stdout
|
|
48
|
+
let m;
|
|
49
|
+
try {
|
|
50
|
+
m = JSON.parse(t);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!isObj(m))
|
|
56
|
+
return;
|
|
57
|
+
const id = m["id"];
|
|
58
|
+
const method = m["method"];
|
|
59
|
+
if (typeof method === "string") {
|
|
60
|
+
if (id === undefined || id === null) {
|
|
61
|
+
this.onNotification(method, m["params"]);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
this.onRequest(method, m["params"]).then((result) => this.write({ id, result }), (e) => this.write({
|
|
65
|
+
id,
|
|
66
|
+
error: { code: e instanceof RpcError ? e.code : -32603, message: e instanceof Error ? e.message : String(e) },
|
|
67
|
+
}));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (typeof id === "number") {
|
|
71
|
+
const p = this.pending.get(id);
|
|
72
|
+
if (!p)
|
|
73
|
+
return;
|
|
74
|
+
this.pending.delete(id);
|
|
75
|
+
const err = m["error"];
|
|
76
|
+
if (isObj(err))
|
|
77
|
+
p.reject(new RpcError(Number(err["code"] ?? -32603), String(err["message"] ?? "rpc error")));
|
|
78
|
+
else
|
|
79
|
+
p.resolve(m["result"]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=rpc.js.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { execFile, execSync, spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { delimiter, dirname, join } from "node:path";
|
|
5
|
+
let npmPrefixBin;
|
|
6
|
+
/** `npm prefix -g` (+ /bin on POSIX): where `npm i -g` puts shims when the prefix was customised. Asked once, best effort. */
|
|
7
|
+
function npmGlobalBin() {
|
|
8
|
+
if (npmPrefixBin === undefined) {
|
|
9
|
+
try {
|
|
10
|
+
const win = process.platform === "win32";
|
|
11
|
+
const out = execSync("npm prefix -g", { encoding: "utf8", timeout: 5000, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
12
|
+
npmPrefixBin = out ? (win ? out : join(out, "bin")) : null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
npmPrefixBin = null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return npmPrefixBin ?? undefined;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Directories where the engine CLIs usually live but a background service (Task Scheduler, launchd, systemd --user)
|
|
22
|
+
* often does not have on PATH: native installer, npm -g, Volta, pnpm, Bun, nvm, Homebrew, scoop.
|
|
23
|
+
*/
|
|
24
|
+
export function extraSearchDirs() {
|
|
25
|
+
const win = process.platform === "win32";
|
|
26
|
+
const home = homedir();
|
|
27
|
+
const env = process.env;
|
|
28
|
+
const dirs = [dirname(process.execPath), join(home, ".local", "bin"), join(home, ".claude", "local")];
|
|
29
|
+
if (win) {
|
|
30
|
+
dirs.push(env["APPDATA"] && join(env["APPDATA"], "npm"), env["LOCALAPPDATA"] && join(env["LOCALAPPDATA"], "pnpm"), env["LOCALAPPDATA"] && join(env["LOCALAPPDATA"], "Volta", "bin"), env["NVM_SYMLINK"], env["ProgramFiles"] && join(env["ProgramFiles"], "nodejs"), join(home, "scoop", "shims"), join(home, ".bun", "bin"));
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
dirs.push(join(home, ".npm-global", "bin"), join(home, ".volta", "bin"), join(home, ".bun", "bin"), join(home, ".local", "share", "pnpm"), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin");
|
|
34
|
+
const nvm = join(env["NVM_DIR"] ?? join(home, ".nvm"), "versions", "node");
|
|
35
|
+
try {
|
|
36
|
+
// Newest node version first, so the CLI installed under the active-most version wins.
|
|
37
|
+
for (const v of readdirSync(nvm).sort().reverse())
|
|
38
|
+
dirs.push(join(nvm, v, "bin"));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* no nvm */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
dirs.push(npmGlobalBin());
|
|
45
|
+
return [...new Set(dirs.filter((d) => !!d))];
|
|
46
|
+
}
|
|
47
|
+
/** Resolve an executable on PATH first, then in the usual install locations (honours PATHEXT on Windows). */
|
|
48
|
+
export function findOnPath(name) {
|
|
49
|
+
const win = process.platform === "win32";
|
|
50
|
+
const exts = win ? (process.env["PATHEXT"] ?? ".EXE;.CMD;.BAT").split(";").map((e) => e.toLowerCase()) : [""];
|
|
51
|
+
const search = (dirs) => {
|
|
52
|
+
for (const dir of dirs) {
|
|
53
|
+
for (const ext of exts) {
|
|
54
|
+
const candidate = join(dir, name + ext);
|
|
55
|
+
if (existsSync(candidate))
|
|
56
|
+
return candidate;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
};
|
|
61
|
+
// The extra locations are only computed (one `npm prefix -g` call at most) when PATH itself has no match.
|
|
62
|
+
return search((process.env["PATH"] ?? process.env["Path"] ?? "").split(delimiter).filter(Boolean)) ?? search(extraSearchDirs());
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Spawn an engine CLI with piped stdio. Windows npm shims (.cmd/.bat) must run through a shell; `args`
|
|
66
|
+
* are fixed adapter constants (never user input), so the simple quoting below is safe.
|
|
67
|
+
*/
|
|
68
|
+
export function spawnCli(file, args, cwd, env) {
|
|
69
|
+
// Background services start with a minimal PATH; npm shims and `#!/usr/bin/env node` scripts need `node` (and the
|
|
70
|
+
// CLI's own folder) on it, so prepend both rather than assume the login shell's PATH.
|
|
71
|
+
const base = { ...process.env, ...env };
|
|
72
|
+
const pathKey = Object.keys(base).find((k) => k.toLowerCase() === "path") ?? "PATH";
|
|
73
|
+
base[pathKey] = [...new Set([dirname(process.execPath), dirname(file), ...(base[pathKey] ?? "").split(delimiter)])].filter(Boolean).join(delimiter);
|
|
74
|
+
const opts = { cwd, windowsHide: true, env: base };
|
|
75
|
+
if (process.platform === "win32" && /\.(cmd|bat)$/i.test(file)) {
|
|
76
|
+
const q = (s) => `"${s}"`;
|
|
77
|
+
return spawn([q(file), ...args.map(q)].join(" "), { ...opts, shell: true });
|
|
78
|
+
}
|
|
79
|
+
return spawn(file, args, opts);
|
|
80
|
+
}
|
|
81
|
+
/** Kill a child and its descendants (a Windows shell wrapper would otherwise leave the CLI running). */
|
|
82
|
+
export function killTree(child) {
|
|
83
|
+
if (child.pid === undefined || child.exitCode !== null)
|
|
84
|
+
return;
|
|
85
|
+
if (process.platform === "win32")
|
|
86
|
+
execFile("taskkill", ["/pid", String(child.pid), "/t", "/f"], () => { });
|
|
87
|
+
else
|
|
88
|
+
child.kill("SIGTERM");
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=util.js.map
|
package/dist/firewall.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { ps } from "./service.js";
|
|
2
|
+
/** Windows Defender Firewall rule that lets the phone reach the host over the LAN (and Tailscale).
|
|
3
|
+
* Without it Windows silently drops inbound connections on a Private network and the app shows "Can't reach <pc>"
|
|
4
|
+
* although the daemon is running. Port-based (survives Node upgrades), Private profile only, remote side limited to the
|
|
5
|
+
* local subnet and the Tailscale range. Creating a rule needs an elevated shell, so failure is normal: callers show
|
|
6
|
+
* `ELEVATE_HINT` instead of failing. macOS/Linux: not applicable (no change is made). */
|
|
7
|
+
export const RULE_NAME = "Remogent Host";
|
|
8
|
+
const REMOTE = "LocalSubnet,100.64.0.0/10";
|
|
9
|
+
export const ELEVATE_HINT = "node dist/index.js firewall --elevate";
|
|
10
|
+
const q = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
11
|
+
function createScript(port) {
|
|
12
|
+
return [
|
|
13
|
+
`Get-NetFirewallRule -DisplayName ${q(RULE_NAME)} -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction Stop`,
|
|
14
|
+
`New-NetFirewallRule -DisplayName ${q(RULE_NAME)} -Description 'Lets the Remogent phone app reach the Remogent Host (inbound TCP, private networks, local subnet and Tailscale only)' ` +
|
|
15
|
+
`-Direction Inbound -Action Allow -Protocol TCP -LocalPort ${port} -Profile Private -RemoteAddress ${REMOTE} -ErrorAction Stop | Out-Null`,
|
|
16
|
+
].join("\n");
|
|
17
|
+
}
|
|
18
|
+
/** Is there an enabled allow rule for this port? */
|
|
19
|
+
export function firewallState(port) {
|
|
20
|
+
if (process.platform !== "win32")
|
|
21
|
+
return { state: "unsupported", detail: "not applicable on this OS" };
|
|
22
|
+
const r = ps(`$r = Get-NetFirewallRule -DisplayName ${q(RULE_NAME)} -ErrorAction SilentlyContinue | Select-Object -First 1\n` +
|
|
23
|
+
`if (-not $r) { 'missing'; return }\n` +
|
|
24
|
+
`$p = ($r | Get-NetFirewallPortFilter).LocalPort\n` +
|
|
25
|
+
`if ($r.Enabled -eq 'True' -and $r.Action -eq 'Allow' -and ($p -contains '${port}')) { 'open' } else { 'stale' }`);
|
|
26
|
+
const word = r.out.trim().split(/\s+/).pop() ?? "";
|
|
27
|
+
if (word === "open")
|
|
28
|
+
return { state: "open", detail: `rule "${RULE_NAME}" allows TCP ${port} on private networks` };
|
|
29
|
+
if (word === "missing")
|
|
30
|
+
return { state: "missing", detail: `no "${RULE_NAME}" rule` };
|
|
31
|
+
if (word === "stale")
|
|
32
|
+
return { state: "stale", detail: `rule "${RULE_NAME}" exists but is disabled or for another port` };
|
|
33
|
+
return { state: "unknown", detail: r.out.trim().split(/\r?\n/)[0] || "could not query the firewall" };
|
|
34
|
+
}
|
|
35
|
+
/** Try to create/replace the rule as the current user. ok=false normally just means "not elevated". */
|
|
36
|
+
export function ensureFirewallRule(port) {
|
|
37
|
+
if (process.platform !== "win32")
|
|
38
|
+
return { ok: true, message: "firewall: nothing to do on this OS" };
|
|
39
|
+
const cur = firewallState(port);
|
|
40
|
+
if (cur.state === "open")
|
|
41
|
+
return { ok: true, message: `firewall: ${cur.detail}` };
|
|
42
|
+
const r = ps(createScript(port));
|
|
43
|
+
if (r.ok && firewallState(port).state === "open")
|
|
44
|
+
return { ok: true, message: `firewall: created rule "${RULE_NAME}" (TCP ${port}, private networks)` };
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
message: `firewall: could not create the "${RULE_NAME}" rule (it needs an administrator). Until it exists the phone can only connect over USB or if the firewall is off.\n` +
|
|
48
|
+
` Fix with one command (a Windows permission prompt appears): ${ELEVATE_HINT}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Run the rule creation in an elevated PowerShell (UAC prompt) and report whether it worked. */
|
|
52
|
+
export function elevateFirewallRule(port) {
|
|
53
|
+
if (process.platform !== "win32")
|
|
54
|
+
return { ok: true, message: "firewall: nothing to do on this OS" };
|
|
55
|
+
const encoded = Buffer.from(createScript(port), "utf16le").toString("base64");
|
|
56
|
+
const r = ps(`try { Start-Process -FilePath powershell.exe -Verb RunAs -Wait -WindowStyle Hidden ` +
|
|
57
|
+
`-ArgumentList '-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-EncodedCommand','${encoded}'; 'started' } catch { 'cancelled' }`);
|
|
58
|
+
if (r.out.includes("cancelled"))
|
|
59
|
+
return { ok: false, message: "firewall: the permission prompt was cancelled; no rule was added" };
|
|
60
|
+
const after = firewallState(port);
|
|
61
|
+
return after.state === "open"
|
|
62
|
+
? { ok: true, message: `firewall: created rule "${RULE_NAME}" (TCP ${port}, private networks, local subnet + Tailscale)` }
|
|
63
|
+
: { ok: false, message: `firewall: rule still not in place (${after.detail}); check the prompt was accepted` };
|
|
64
|
+
}
|
|
65
|
+
/** Remove the rule (used by `uninstall`). Best effort; needs elevation like creation. */
|
|
66
|
+
export function removeFirewallRule() {
|
|
67
|
+
if (process.platform !== "win32")
|
|
68
|
+
return { ok: true, message: "" };
|
|
69
|
+
const r = ps(`Get-NetFirewallRule -DisplayName ${q(RULE_NAME)} -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction Stop`);
|
|
70
|
+
const left = firewallState(0).state === "missing";
|
|
71
|
+
return r.ok || left
|
|
72
|
+
? { ok: true, message: `firewall: rule "${RULE_NAME}" removed` }
|
|
73
|
+
: { ok: false, message: `firewall: could not remove the "${RULE_NAME}" rule (needs an administrator); delete it in Windows Defender Firewall if you want it gone` };
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=firewall.js.map
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { randomBytes, X509Certificate } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import selfsigned from "selfsigned";
|
|
6
|
+
export const REMOGENT_DIR = join(homedir(), ".remogent");
|
|
7
|
+
const IDENTITY_FILE = join(REMOGENT_DIR, "identity.json");
|
|
8
|
+
export const DEFAULT_PORT = 9876;
|
|
9
|
+
function fingerprintOf(certPem) {
|
|
10
|
+
return new X509Certificate(certPem).fingerprint256.replace(/:/g, "");
|
|
11
|
+
}
|
|
12
|
+
function generateCert() {
|
|
13
|
+
const pems = selfsigned.generate([{ name: "commonName", value: "remogent-host" }], {
|
|
14
|
+
keySize: 2048,
|
|
15
|
+
days: 3650,
|
|
16
|
+
algorithm: "sha256",
|
|
17
|
+
});
|
|
18
|
+
return { certPem: pems.cert, keyPem: pems.private };
|
|
19
|
+
}
|
|
20
|
+
/** The port stored with the identity (what the installed host and the paired phones use), if an identity exists. */
|
|
21
|
+
export function savedPort() {
|
|
22
|
+
try {
|
|
23
|
+
const p = JSON.parse(readFileSync(IDENTITY_FILE, "utf8")).port;
|
|
24
|
+
return typeof p === "number" && p > 0 ? p : undefined;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Load the persisted identity, or create one (token + self-signed TLS cert).
|
|
31
|
+
* `port` overrides the stored port for this run only; add `persistPort` (used by `install`) to also store it. */
|
|
32
|
+
export function loadOrCreateIdentity(opts = {}) {
|
|
33
|
+
mkdirSync(REMOGENT_DIR, { recursive: true });
|
|
34
|
+
if (!opts.rotate && existsSync(IDENTITY_FILE)) {
|
|
35
|
+
const saved = JSON.parse(readFileSync(IDENTITY_FILE, "utf8"));
|
|
36
|
+
if (opts.port) {
|
|
37
|
+
const changed = saved.port !== opts.port;
|
|
38
|
+
saved.port = opts.port;
|
|
39
|
+
if (changed && opts.persistPort)
|
|
40
|
+
writeFileSync(IDENTITY_FILE, JSON.stringify(saved, null, 2), { mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
return saved;
|
|
43
|
+
}
|
|
44
|
+
const { certPem, keyPem } = generateCert();
|
|
45
|
+
const identity = {
|
|
46
|
+
name: hostname(),
|
|
47
|
+
port: opts.port ?? DEFAULT_PORT,
|
|
48
|
+
token: randomBytes(32).toString("base64url"),
|
|
49
|
+
certPem,
|
|
50
|
+
keyPem,
|
|
51
|
+
fingerprint: fingerprintOf(certPem),
|
|
52
|
+
};
|
|
53
|
+
writeFileSync(IDENTITY_FILE, JSON.stringify(identity, null, 2), { mode: 0o600 });
|
|
54
|
+
return identity;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=identity.js.map
|