@nema-project/nema 0.0.0-stage → 0.1.3
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 +21 -0
- package/dist/chunks/chunk-HEIQINX3.js +825 -0
- package/dist/chunks/chunk-WT3XUFWU.js +86 -0
- package/dist/claude/adapter.d.ts +19 -0
- package/dist/claude/adapter.js +258 -0
- package/dist/claude/events.d.ts +29 -0
- package/dist/claude/events.js +75 -0
- package/dist/claude/launch.d.ts +22 -0
- package/dist/claude/launch.js +80 -0
- package/dist/claude/token.d.ts +9 -0
- package/dist/claude/token.js +87 -0
- package/dist/codex/adapter.d.ts +17 -0
- package/dist/codex/adapter.js +151 -0
- package/dist/codex/events.d.ts +16 -0
- package/dist/codex/events.js +47 -0
- package/dist/codex/isolation.d.ts +33 -0
- package/dist/codex/isolation.js +115 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.js +75 -0
- package/dist/configCommands.d.ts +13 -0
- package/dist/configCommands.js +69 -0
- package/dist/daemon/cleanup.d.ts +5 -0
- package/dist/daemon/cleanup.js +49 -0
- package/dist/daemon/connection.d.ts +22 -0
- package/dist/daemon/connection.js +166 -0
- package/dist/daemon/rpc.d.ts +14 -0
- package/dist/daemon/rpc.js +20 -0
- package/dist/daemon/runner.d.ts +34 -0
- package/dist/daemon/runner.js +94 -0
- package/dist/daemon/setup.d.ts +39 -0
- package/dist/daemon/setup.js +95 -0
- package/dist/direct/adapter.d.ts +30 -0
- package/dist/direct/adapter.js +190 -0
- package/dist/direct/authCommands.d.ts +54 -0
- package/dist/direct/authCommands.js +268 -0
- package/dist/direct/authStore.d.ts +120 -0
- package/dist/direct/authStore.js +351 -0
- package/dist/direct/byok.d.ts +27 -0
- package/dist/direct/byok.js +67 -0
- package/dist/direct/codexLogin.d.ts +34 -0
- package/dist/direct/codexLogin.js +93 -0
- package/dist/direct/jwt.d.ts +4 -0
- package/dist/direct/jwt.js +22 -0
- package/dist/direct/lock.d.ts +16 -0
- package/dist/direct/lock.js +134 -0
- package/dist/direct/loop.d.ts +33 -0
- package/dist/direct/loop.js +110 -0
- package/dist/direct/siwc.d.ts +63 -0
- package/dist/direct/siwc.js +403 -0
- package/dist/direct/tools.d.ts +17 -0
- package/dist/direct/tools.js +104 -0
- package/dist/direct/transport.d.ts +177 -0
- package/dist/direct/transport.js +437 -0
- package/dist/doctor.d.ts +28 -0
- package/dist/doctor.js +178 -0
- package/dist/extensions/handlers.d.ts +2 -0
- package/dist/extensions/handlers.js +10 -0
- package/dist/extensions/store.d.ts +11 -0
- package/dist/extensions/store.js +132 -0
- package/dist/github/client.d.ts +47 -0
- package/dist/github/client.js +383 -0
- package/dist/github/gh.d.ts +56 -0
- package/dist/github/gh.js +385 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +144 -0
- package/dist/localSettings.d.ts +8 -0
- package/dist/localSettings.js +26 -0
- package/dist/mcp/server.d.ts +5 -0
- package/dist/mcp/server.js +83 -0
- package/dist/ownedProcess.d.ts +39 -0
- package/dist/ownedProcess.js +229 -0
- package/dist/platform.d.ts +10 -0
- package/dist/platform.js +60 -0
- package/dist/report/commands.d.ts +41 -0
- package/dist/report/commands.js +159 -0
- package/dist/report/desktop.d.ts +3 -0
- package/dist/report/desktop.js +123 -0
- package/dist/report/git.d.ts +5 -0
- package/dist/report/git.js +42 -0
- package/dist/report/install.d.ts +22 -0
- package/dist/report/install.js +183 -0
- package/dist/report/send.d.ts +35 -0
- package/dist/report/send.js +208 -0
- package/dist/report/token.d.ts +5 -0
- package/dist/report/token.js +86 -0
- package/dist/repos/git.d.ts +48 -0
- package/dist/repos/git.js +370 -0
- package/dist/repos/graphify.d.ts +32 -0
- package/dist/repos/graphify.js +292 -0
- package/dist/repos/handlers.d.ts +20 -0
- package/dist/repos/handlers.js +255 -0
- package/dist/repos/indexState.d.ts +10 -0
- package/dist/repos/indexState.js +45 -0
- package/dist/repos/pickDirectory.d.ts +11 -0
- package/dist/repos/pickDirectory.js +140 -0
- package/dist/repos/reportCheck.d.ts +13 -0
- package/dist/repos/reportCheck.js +268 -0
- package/dist/repos/watcher.d.ts +64 -0
- package/dist/repos/watcher.js +202 -0
- package/dist/room/client.d.ts +26 -0
- package/dist/room/client.js +76 -0
- package/dist/secrets/file.d.ts +12 -0
- package/dist/secrets/file.js +89 -0
- package/dist/secrets/github.d.ts +10 -0
- package/dist/secrets/github.js +29 -0
- package/dist/secrets/permissions.d.ts +7 -0
- package/dist/secrets/permissions.js +86 -0
- package/dist/systemGit.d.ts +14 -0
- package/dist/systemGit.js +75 -0
- package/dist/wp/commands.d.ts +15 -0
- package/dist/wp/commands.js +103 -0
- package/package.json +26 -4
- package/src/report/SKILL.md +21 -0
- package/README.md +0 -3
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// report/git.js
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
function git(cwd, args) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
execFile("git", ["-C", cwd, ...args], {
|
|
7
|
+
// A minimal env, so GIT_DIR / GIT_WORK_TREE set by the agent's environment cannot redirect git.
|
|
8
|
+
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", LANG: process.env.LANG ?? "", GIT_TERMINAL_PROMPT: "0" },
|
|
9
|
+
shell: false,
|
|
10
|
+
timeout: 5e3,
|
|
11
|
+
encoding: "utf8"
|
|
12
|
+
}, (err, stdout) => err ? reject(err) : resolve(String(stdout).trim()));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
async function repoInfo(cwd) {
|
|
16
|
+
let commonDir;
|
|
17
|
+
let head;
|
|
18
|
+
try {
|
|
19
|
+
commonDir = await git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
20
|
+
head = await git(cwd, ["rev-parse", "HEAD"]);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (!/^[0-9a-f]{40}$/.test(head))
|
|
25
|
+
return null;
|
|
26
|
+
let branch;
|
|
27
|
+
try {
|
|
28
|
+
branch = await git(cwd, ["symbolic-ref", "--short", "-q", "HEAD"]) || null;
|
|
29
|
+
} catch {
|
|
30
|
+
branch = null;
|
|
31
|
+
}
|
|
32
|
+
let gitCommonDir;
|
|
33
|
+
try {
|
|
34
|
+
gitCommonDir = await realpath(commonDir);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return { gitCommonDir, branch, head };
|
|
39
|
+
}
|
|
40
|
+
export {
|
|
41
|
+
repoInfo
|
|
42
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Out } from "../direct/authCommands.js";
|
|
2
|
+
export type ReportAgent = "claude" | "codex";
|
|
3
|
+
export declare const REPORT_AGENTS: readonly ReportAgent[];
|
|
4
|
+
export declare function hookCommand(nodeBin: string, cliEntry: string, agent: ReportAgent, platform?: NodeJS.Platform): string;
|
|
5
|
+
export type InstallRun = {
|
|
6
|
+
agent: ReportAgent;
|
|
7
|
+
home: string;
|
|
8
|
+
nemaHome: string;
|
|
9
|
+
out: Out;
|
|
10
|
+
nodeBin: string;
|
|
11
|
+
cliEntry: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function installReport(o: InstallRun): number;
|
|
14
|
+
export type UninstallRun = {
|
|
15
|
+
agent: ReportAgent;
|
|
16
|
+
home: string;
|
|
17
|
+
out: Out;
|
|
18
|
+
};
|
|
19
|
+
/** Removes exactly Nema's hook entries and the nema-report skill dir. The report token stays. */
|
|
20
|
+
export declare function uninstallReport(o: UninstallRun): number;
|
|
21
|
+
/** For `nema doctor`: whether the agent's settings carry a Nema Stop or SessionEnd hook. Never throws. */
|
|
22
|
+
export declare function reportHookInstalled(agent: ReportAgent, home: string): boolean;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// report/install.js
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ensureReportToken, reportTokenPath } from "./token.js";
|
|
5
|
+
var REPORT_AGENTS = ["claude", "codex"];
|
|
6
|
+
var EVENTS = ["Stop", "SessionEnd"];
|
|
7
|
+
var MARKER = " report hook --agent ";
|
|
8
|
+
var SKILL_NAME = "nema-report";
|
|
9
|
+
function agentPaths(agent, home) {
|
|
10
|
+
return agent === "claude" ? { settings: path.join(home, ".claude", "settings.json"), skillDir: path.join(home, ".claude", "skills", SKILL_NAME) } : { settings: path.join(home, ".codex", "hooks.json"), skillDir: path.join(home, ".agents", "skills", SKILL_NAME) };
|
|
11
|
+
}
|
|
12
|
+
var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
13
|
+
var isNema = (h) => {
|
|
14
|
+
if (!isObj(h) || typeof h.command !== "string")
|
|
15
|
+
return false;
|
|
16
|
+
if (h.command.includes(MARKER))
|
|
17
|
+
return true;
|
|
18
|
+
const encoded = /^powershell\.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ([A-Za-z0-9+/=]+)$/.exec(h.command);
|
|
19
|
+
return encoded !== null && Buffer.from(encoded[1], "base64").toString("utf16le").includes(MARKER);
|
|
20
|
+
};
|
|
21
|
+
function shellQuote(s) {
|
|
22
|
+
return /^[A-Za-z0-9_\/.,:@%+=-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
23
|
+
}
|
|
24
|
+
function hookCommand(nodeBin, cliEntry, agent, platform = process.platform) {
|
|
25
|
+
if (platform === "win32") {
|
|
26
|
+
const psQuote = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
27
|
+
const script = `& ${psQuote(nodeBin)} ${psQuote(cliEntry)} report hook --agent ${agent}; exit $LASTEXITCODE`;
|
|
28
|
+
return `powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, "utf16le").toString("base64")}`;
|
|
29
|
+
}
|
|
30
|
+
return `${shellQuote(nodeBin)} ${shellQuote(cliEntry)} report hook --agent ${agent}`;
|
|
31
|
+
}
|
|
32
|
+
function load(file) {
|
|
33
|
+
if (!existsSync(file))
|
|
34
|
+
return { data: {}, indent: " ", newline: true, existed: false };
|
|
35
|
+
const raw = readFileSync(file, "utf8");
|
|
36
|
+
let data;
|
|
37
|
+
try {
|
|
38
|
+
data = JSON.parse(raw);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (!isObj(data) || data.hooks !== void 0 && !isObj(data.hooks))
|
|
43
|
+
return null;
|
|
44
|
+
if (isObj(data.hooks) && Object.values(data.hooks).some((v) => !Array.isArray(v)))
|
|
45
|
+
return null;
|
|
46
|
+
const indent = /\n([ \t]+)\S/.exec(raw)?.[1] ?? " ";
|
|
47
|
+
return { data, indent, newline: raw.endsWith("\n"), existed: true };
|
|
48
|
+
}
|
|
49
|
+
function save(file, l) {
|
|
50
|
+
const target = l.existed ? realpathSync(file) : file;
|
|
51
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
52
|
+
const mode = l.existed ? statSync(target).mode & 511 : 420;
|
|
53
|
+
const tmp = `${target}.${process.pid}.nema.tmp`;
|
|
54
|
+
writeFileSync(tmp, `${JSON.stringify(l.data, null, l.indent)}${l.newline ? "\n" : ""}`, { mode });
|
|
55
|
+
renameSync(tmp, target);
|
|
56
|
+
}
|
|
57
|
+
function putHook(hooks, event, command) {
|
|
58
|
+
const groups = hooks[event] ??= [];
|
|
59
|
+
let placed = false;
|
|
60
|
+
const kept = [];
|
|
61
|
+
for (const g of groups) {
|
|
62
|
+
if (!isObj(g) || !Array.isArray(g.hooks) || !g.hooks.some(isNema)) {
|
|
63
|
+
kept.push(g);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const inner = [];
|
|
67
|
+
for (const h of g.hooks) {
|
|
68
|
+
if (!isNema(h))
|
|
69
|
+
inner.push(h);
|
|
70
|
+
else if (!placed) {
|
|
71
|
+
placed = true;
|
|
72
|
+
inner.push({ ...h, type: "command", command });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (inner.length > 0)
|
|
76
|
+
kept.push({ ...g, hooks: inner });
|
|
77
|
+
}
|
|
78
|
+
if (!placed)
|
|
79
|
+
kept.push({ hooks: [{ type: "command", command }] });
|
|
80
|
+
hooks[event] = kept;
|
|
81
|
+
}
|
|
82
|
+
function stripHooks(data) {
|
|
83
|
+
if (!isObj(data.hooks))
|
|
84
|
+
return false;
|
|
85
|
+
const hooks = data.hooks;
|
|
86
|
+
let changed = false;
|
|
87
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
88
|
+
if (!Array.isArray(groups))
|
|
89
|
+
continue;
|
|
90
|
+
let touched = false;
|
|
91
|
+
const kept = [];
|
|
92
|
+
for (const g of groups) {
|
|
93
|
+
if (!isObj(g) || !Array.isArray(g.hooks) || !g.hooks.some(isNema)) {
|
|
94
|
+
kept.push(g);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
touched = true;
|
|
98
|
+
const inner = g.hooks.filter((h) => !isNema(h));
|
|
99
|
+
if (inner.length > 0)
|
|
100
|
+
kept.push({ ...g, hooks: inner });
|
|
101
|
+
}
|
|
102
|
+
if (!touched)
|
|
103
|
+
continue;
|
|
104
|
+
changed = true;
|
|
105
|
+
if (kept.length > 0)
|
|
106
|
+
hooks[event] = kept;
|
|
107
|
+
else
|
|
108
|
+
delete hooks[event];
|
|
109
|
+
}
|
|
110
|
+
if (changed && Object.keys(hooks).length === 0)
|
|
111
|
+
delete data.hooks;
|
|
112
|
+
return changed;
|
|
113
|
+
}
|
|
114
|
+
var skillSource = () => readFileSync(new URL("../../src/report/SKILL.md", import.meta.url), "utf8");
|
|
115
|
+
function installReport(o) {
|
|
116
|
+
const p = agentPaths(o.agent, o.home);
|
|
117
|
+
const loaded = load(p.settings);
|
|
118
|
+
if (!loaded) {
|
|
119
|
+
o.out.error(`\u65E0\u6CD5\u89E3\u6790 ${p.settings}\uFF0C\u672A\u4FEE\u6539`);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
ensureReportToken(o.nemaHome);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
o.out.error(e instanceof Error ? e.message : String(e));
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
mkdirSync(p.skillDir, { recursive: true });
|
|
129
|
+
const skillFile = path.join(p.skillDir, "SKILL.md");
|
|
130
|
+
writeFileSync(skillFile, skillSource());
|
|
131
|
+
o.out.log(`\u5DF2\u5199\u5165\u6280\u80FD\uFF1A${skillFile}`);
|
|
132
|
+
const before = JSON.stringify(loaded.data);
|
|
133
|
+
const hooks = loaded.data.hooks ??= {};
|
|
134
|
+
const command = hookCommand(o.nodeBin, o.cliEntry, o.agent);
|
|
135
|
+
for (const ev of EVENTS)
|
|
136
|
+
putHook(hooks, ev, command);
|
|
137
|
+
if (JSON.stringify(loaded.data) === before) {
|
|
138
|
+
o.out.log(`${p.settings} \u91CC\u7684 ${EVENTS.join("\u3001")} \u94A9\u5B50\u5DF2\u662F\u6700\u65B0`);
|
|
139
|
+
} else {
|
|
140
|
+
save(p.settings, loaded);
|
|
141
|
+
o.out.log(`\u5DF2\u5728 ${p.settings} \u5199\u5165 ${EVENTS.join("\u3001")} \u94A9\u5B50`);
|
|
142
|
+
}
|
|
143
|
+
o.out.log(`\u4E0A\u62A5\u4EE4\u724C\u5DF2\u5C31\u7EEA\uFF1A${reportTokenPath(o.nemaHome)}`);
|
|
144
|
+
o.out.log("\u8BF7\u91CD\u542F nema daemon\uFF0C\u8BA9\u5B83\u767B\u8BB0\u4E0A\u62A5\u4EE4\u724C");
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
function uninstallReport(o) {
|
|
148
|
+
const p = agentPaths(o.agent, o.home);
|
|
149
|
+
const loaded = load(p.settings);
|
|
150
|
+
if (!loaded) {
|
|
151
|
+
o.out.error(`\u65E0\u6CD5\u89E3\u6790 ${p.settings}\uFF0C\u672A\u4FEE\u6539`);
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
if (loaded.existed && stripHooks(loaded.data)) {
|
|
155
|
+
save(p.settings, loaded);
|
|
156
|
+
o.out.log(`\u5DF2\u4ECE ${p.settings} \u79FB\u9664 Nema \u7684\u94A9\u5B50`);
|
|
157
|
+
} else {
|
|
158
|
+
o.out.log(`${p.settings} \u91CC\u6CA1\u6709 Nema \u7684\u94A9\u5B50`);
|
|
159
|
+
}
|
|
160
|
+
if (existsSync(p.skillDir)) {
|
|
161
|
+
rmSync(p.skillDir, { recursive: true, force: true });
|
|
162
|
+
o.out.log(`\u5DF2\u5220\u9664\u6280\u80FD\uFF1A${p.skillDir}`);
|
|
163
|
+
}
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
function reportHookInstalled(agent, home) {
|
|
167
|
+
try {
|
|
168
|
+
const loaded = load(agentPaths(agent, home).settings);
|
|
169
|
+
if (!loaded || !isObj(loaded.data.hooks))
|
|
170
|
+
return false;
|
|
171
|
+
const hooks = loaded.data.hooks;
|
|
172
|
+
return EVENTS.some((ev) => hooks[ev]?.some((g) => isObj(g) && Array.isArray(g.hooks) && g.hooks.some(isNema)));
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export {
|
|
178
|
+
REPORT_AGENTS,
|
|
179
|
+
hookCommand,
|
|
180
|
+
installReport,
|
|
181
|
+
reportHookInstalled,
|
|
182
|
+
uninstallReport
|
|
183
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type HubPath = "/report/hook" | "/report";
|
|
2
|
+
export type SendResult = "ok" | "dropped" | "failed";
|
|
3
|
+
export type SendOpts = {
|
|
4
|
+
connectMs: number;
|
|
5
|
+
totalMs: number;
|
|
6
|
+
};
|
|
7
|
+
export declare const HOOK_TIMEOUTS: SendOpts;
|
|
8
|
+
/** POSTs JSON with a bearer token. Never throws: network errors and timeouts are status 0. */
|
|
9
|
+
export declare function postToHub(hubUrl: string, p: HubPath, token: string, body: unknown, opts?: SendOpts): Promise<{
|
|
10
|
+
status: number;
|
|
11
|
+
text: string;
|
|
12
|
+
}>;
|
|
13
|
+
/** GETs with a bearer token. Never throws: network errors and timeouts are status 0. */
|
|
14
|
+
export declare function getFromHub(hubUrl: string, p: string, token: string, opts?: SendOpts): Promise<{
|
|
15
|
+
status: number;
|
|
16
|
+
text: string;
|
|
17
|
+
}>;
|
|
18
|
+
export declare function classify(status: number): SendResult;
|
|
19
|
+
/** 2xx (204 too: dropped hub-side) is "ok"; 4xx is "dropped"; network failure, timeout or 5xx is "failed". */
|
|
20
|
+
export declare function sendToHub(hubUrl: string, p: HubPath, token: string, body: unknown, opts?: SendOpts): Promise<SendResult>;
|
|
21
|
+
export declare const SPOOL_MAX_BYTES: number;
|
|
22
|
+
export type SpoolLine = {
|
|
23
|
+
path: HubPath;
|
|
24
|
+
body: unknown;
|
|
25
|
+
spooledAt: string;
|
|
26
|
+
};
|
|
27
|
+
export declare const spoolPath: (nemaHome: string) => string;
|
|
28
|
+
/** Appends one line (0600). Returns false, writing nothing, when the spool would grow past 5 MB. */
|
|
29
|
+
export declare function appendSpool(nemaHome: string, line: SpoolLine): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Replays the spool oldest first and stops at the first "failed". "ok" and "dropped" lines are removed;
|
|
32
|
+
* lines older than 7 days or malformed are dropped unsent. The rest is rewritten atomically.
|
|
33
|
+
* Returns "failed" when something is still waiting, else "ok".
|
|
34
|
+
*/
|
|
35
|
+
export declare function flushSpool(nemaHome: string, send: (line: SpoolLine) => Promise<SendResult>, now?: number): Promise<"ok" | "failed">;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// report/send.js
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from "node:fs";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import https from "node:https";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { openPrivateFile, protectPrivatePath } from "../secrets/permissions.js";
|
|
8
|
+
var HOOK_TIMEOUTS = { connectMs: 500, totalMs: 1500 };
|
|
9
|
+
function postToHub(hubUrl, p, token, body, opts = HOOK_TIMEOUTS) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
let done = false;
|
|
12
|
+
const finish = (status, text) => {
|
|
13
|
+
if (done)
|
|
14
|
+
return;
|
|
15
|
+
done = true;
|
|
16
|
+
clearTimeout(total);
|
|
17
|
+
clearTimeout(connect);
|
|
18
|
+
resolve({ status, text });
|
|
19
|
+
};
|
|
20
|
+
let url;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(hubUrl.replace(/\/+$/, "") + p);
|
|
23
|
+
} catch {
|
|
24
|
+
resolve({ status: 0, text: "" });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const data = Buffer.from(JSON.stringify(body));
|
|
28
|
+
const lib = url.protocol === "https:" ? https : http;
|
|
29
|
+
const req = lib.request(url, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "content-type": "application/json", "content-length": String(data.length), authorization: `Bearer ${token}` }
|
|
32
|
+
});
|
|
33
|
+
const connect = setTimeout(() => req.destroy(new Error("connect timeout")), opts.connectMs);
|
|
34
|
+
const total = setTimeout(() => req.destroy(new Error("timeout")), opts.totalMs);
|
|
35
|
+
req.on("socket", (s) => {
|
|
36
|
+
if (!s.connecting)
|
|
37
|
+
clearTimeout(connect);
|
|
38
|
+
else
|
|
39
|
+
s.once("connect", () => clearTimeout(connect));
|
|
40
|
+
});
|
|
41
|
+
req.on("error", () => finish(0, ""));
|
|
42
|
+
req.on("response", (res) => {
|
|
43
|
+
let text = "";
|
|
44
|
+
res.setEncoding("utf8");
|
|
45
|
+
res.on("data", (c) => {
|
|
46
|
+
if (text.length < 4096)
|
|
47
|
+
text += c;
|
|
48
|
+
});
|
|
49
|
+
res.on("end", () => finish(res.statusCode ?? 0, text));
|
|
50
|
+
res.on("error", () => finish(0, ""));
|
|
51
|
+
});
|
|
52
|
+
req.end(data);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function getFromHub(hubUrl, p, token, opts = { connectMs: 2e3, totalMs: 15e3 }) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
let done = false;
|
|
58
|
+
const finish = (status, text) => {
|
|
59
|
+
if (done)
|
|
60
|
+
return;
|
|
61
|
+
done = true;
|
|
62
|
+
clearTimeout(total);
|
|
63
|
+
clearTimeout(connect);
|
|
64
|
+
resolve({ status, text });
|
|
65
|
+
};
|
|
66
|
+
let url;
|
|
67
|
+
try {
|
|
68
|
+
url = new URL(hubUrl.replace(/\/+$/, "") + p);
|
|
69
|
+
} catch {
|
|
70
|
+
resolve({ status: 0, text: "" });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const lib = url.protocol === "https:" ? https : http;
|
|
74
|
+
const req = lib.request(url, { method: "GET", headers: { authorization: `Bearer ${token}` } });
|
|
75
|
+
const connect = setTimeout(() => req.destroy(new Error("connect timeout")), opts.connectMs);
|
|
76
|
+
const total = setTimeout(() => req.destroy(new Error("timeout")), opts.totalMs);
|
|
77
|
+
req.on("socket", (s) => {
|
|
78
|
+
if (!s.connecting)
|
|
79
|
+
clearTimeout(connect);
|
|
80
|
+
else
|
|
81
|
+
s.once("connect", () => clearTimeout(connect));
|
|
82
|
+
});
|
|
83
|
+
req.on("error", () => finish(0, ""));
|
|
84
|
+
req.on("response", (res) => {
|
|
85
|
+
const chunks = [];
|
|
86
|
+
res.setEncoding("utf8");
|
|
87
|
+
res.on("data", (c) => chunks.push(c));
|
|
88
|
+
res.on("end", () => finish(res.statusCode ?? 0, chunks.join("")));
|
|
89
|
+
res.on("error", () => finish(0, ""));
|
|
90
|
+
});
|
|
91
|
+
req.end();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function classify(status) {
|
|
95
|
+
if (status >= 200 && status < 300)
|
|
96
|
+
return "ok";
|
|
97
|
+
if (status >= 400 && status < 500)
|
|
98
|
+
return "dropped";
|
|
99
|
+
return "failed";
|
|
100
|
+
}
|
|
101
|
+
async function sendToHub(hubUrl, p, token, body, opts = HOOK_TIMEOUTS) {
|
|
102
|
+
return classify((await postToHub(hubUrl, p, token, body, opts)).status);
|
|
103
|
+
}
|
|
104
|
+
var SPOOL_MAX_BYTES = 5 * 1024 * 1024;
|
|
105
|
+
var SPOOL_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
106
|
+
var spoolPath = (nemaHome) => path.join(nemaHome, "report-spool.jsonl");
|
|
107
|
+
function appendSpool(nemaHome, line) {
|
|
108
|
+
mkdirSync(nemaHome, { recursive: true, mode: 448 });
|
|
109
|
+
if (process.platform === "win32")
|
|
110
|
+
protectPrivatePath(nemaHome, 448);
|
|
111
|
+
try {
|
|
112
|
+
if (lstatSync(spoolPath(nemaHome)).isSymbolicLink())
|
|
113
|
+
throw new Error("\u4E0A\u62A5\u7F13\u5B58\u4E0D\u80FD\u662F\u7B26\u53F7\u94FE\u63A5");
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error.code !== "ENOENT")
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
const text = JSON.stringify(line) + "\n";
|
|
119
|
+
const fd = openSync(spoolPath(nemaHome), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 384);
|
|
120
|
+
try {
|
|
121
|
+
const st = fstatSync(fd);
|
|
122
|
+
if (!st.isFile())
|
|
123
|
+
return false;
|
|
124
|
+
if (process.platform === "win32")
|
|
125
|
+
protectPrivatePath(spoolPath(nemaHome));
|
|
126
|
+
if (process.platform !== "win32" && (st.mode & 511) !== 384)
|
|
127
|
+
fchmodSync(fd, 384);
|
|
128
|
+
if (st.size + Buffer.byteLength(text) > SPOOL_MAX_BYTES)
|
|
129
|
+
return false;
|
|
130
|
+
writeSync(fd, text);
|
|
131
|
+
return true;
|
|
132
|
+
} finally {
|
|
133
|
+
closeSync(fd);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function readSpool(nemaHome) {
|
|
137
|
+
let fd;
|
|
138
|
+
try {
|
|
139
|
+
fd = openPrivateFile(spoolPath(nemaHome));
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (e.code === "ENOENT")
|
|
142
|
+
return null;
|
|
143
|
+
throw e;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
if (!fstatSync(fd).isFile())
|
|
147
|
+
return null;
|
|
148
|
+
return readFileSync(fd, "utf8");
|
|
149
|
+
} finally {
|
|
150
|
+
closeSync(fd);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function writeSpool(nemaHome, lines) {
|
|
154
|
+
const file = spoolPath(nemaHome);
|
|
155
|
+
if (lines.length === 0) {
|
|
156
|
+
try {
|
|
157
|
+
unlinkSync(file);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
if (e.code !== "ENOENT")
|
|
160
|
+
throw e;
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
165
|
+
const fd = openSync(tmp, "wx", 384);
|
|
166
|
+
try {
|
|
167
|
+
writeSync(fd, lines.map((l) => JSON.stringify(l) + "\n").join(""));
|
|
168
|
+
} finally {
|
|
169
|
+
closeSync(fd);
|
|
170
|
+
}
|
|
171
|
+
renameSync(tmp, file);
|
|
172
|
+
}
|
|
173
|
+
function parseLine(raw) {
|
|
174
|
+
try {
|
|
175
|
+
const v = JSON.parse(raw);
|
|
176
|
+
if ((v.path === "/report/hook" || v.path === "/report") && typeof v.spooledAt === "string" && v.body !== void 0)
|
|
177
|
+
return v;
|
|
178
|
+
} catch {
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
async function flushSpool(nemaHome, send, now = Date.now()) {
|
|
183
|
+
const raw = readSpool(nemaHome);
|
|
184
|
+
if (raw === null)
|
|
185
|
+
return "ok";
|
|
186
|
+
const lines = raw.split("\n").filter((l) => l !== "").map(parseLine).filter((l) => l !== null && now - Date.parse(l.spooledAt) <= SPOOL_MAX_AGE_MS);
|
|
187
|
+
let i = 0;
|
|
188
|
+
let result = "ok";
|
|
189
|
+
for (; i < lines.length; i++) {
|
|
190
|
+
if (await send(lines[i]) === "failed") {
|
|
191
|
+
result = "failed";
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
writeSpool(nemaHome, lines.slice(i));
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
export {
|
|
199
|
+
HOOK_TIMEOUTS,
|
|
200
|
+
SPOOL_MAX_BYTES,
|
|
201
|
+
appendSpool,
|
|
202
|
+
classify,
|
|
203
|
+
flushSpool,
|
|
204
|
+
getFromHub,
|
|
205
|
+
postToHub,
|
|
206
|
+
sendToHub,
|
|
207
|
+
spoolPath
|
|
208
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const reportTokenPath: (nemaHome: string) => string;
|
|
2
|
+
/** Creates the token if missing (0600, atomic, never overwriting a concurrent writer's), else reads and checks it. */
|
|
3
|
+
export declare function ensureReportToken(nemaHome: string): string;
|
|
4
|
+
/** sha256 hex of the token, or null when the token file does not exist. */
|
|
5
|
+
export declare function reportTokenHash(nemaHome: string): string | null;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// report/token.js
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { openPrivateFile, privateModeMatches, protectPrivatePath } from "../secrets/permissions.js";
|
|
6
|
+
var TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
7
|
+
var SYMLINK = "\u4E0A\u62A5\u4EE4\u724C\u6587\u4EF6\u662F\u7B26\u53F7\u94FE\u63A5\uFF0C\u5DF2\u62D2\u7EDD";
|
|
8
|
+
var BAD_MODE = "\u4E0A\u62A5\u4EE4\u724C\u6587\u4EF6\u6743\u9650\u5FC5\u987B\u662F 0600";
|
|
9
|
+
var BAD_TOKEN = "\u4E0A\u62A5\u4EE4\u724C\u6587\u4EF6\u5185\u5BB9\u65E0\u6548";
|
|
10
|
+
var reportTokenPath = (nemaHome) => path.join(nemaHome, "report-token");
|
|
11
|
+
function readReportToken(nemaHome) {
|
|
12
|
+
let fd;
|
|
13
|
+
try {
|
|
14
|
+
fd = openPrivateFile(reportTokenPath(nemaHome));
|
|
15
|
+
} catch (e) {
|
|
16
|
+
const code = e.code;
|
|
17
|
+
if (code === "ENOENT")
|
|
18
|
+
return null;
|
|
19
|
+
if (code === "ELOOP")
|
|
20
|
+
throw new Error(SYMLINK);
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const st = fstatSync(fd);
|
|
25
|
+
if (!st.isFile() || !privateModeMatches(st))
|
|
26
|
+
throw new Error(BAD_MODE);
|
|
27
|
+
const token = readFileSync(fd, "utf8").trim();
|
|
28
|
+
if (!TOKEN_RE.test(token))
|
|
29
|
+
throw new Error(BAD_TOKEN);
|
|
30
|
+
return token;
|
|
31
|
+
} finally {
|
|
32
|
+
closeSync(fd);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function ensureReportToken(nemaHome) {
|
|
36
|
+
const existing = readReportToken(nemaHome);
|
|
37
|
+
if (existing !== null)
|
|
38
|
+
return existing;
|
|
39
|
+
const file = reportTokenPath(nemaHome);
|
|
40
|
+
try {
|
|
41
|
+
if (lstatSync(file).isSymbolicLink())
|
|
42
|
+
throw new Error(SYMLINK);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
if (e.code !== "ENOENT")
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
mkdirSync(nemaHome, { recursive: true, mode: 448 });
|
|
48
|
+
protectPrivatePath(nemaHome, 448);
|
|
49
|
+
const token = randomBytes(32).toString("hex");
|
|
50
|
+
const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
51
|
+
const fd = openSync(tmp, "wx", 384);
|
|
52
|
+
try {
|
|
53
|
+
try {
|
|
54
|
+
writeSync(fd, token);
|
|
55
|
+
fsyncSync(fd);
|
|
56
|
+
} finally {
|
|
57
|
+
closeSync(fd);
|
|
58
|
+
}
|
|
59
|
+
protectPrivatePath(tmp, 384);
|
|
60
|
+
try {
|
|
61
|
+
linkSync(tmp, file);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (e.code !== "EEXIST")
|
|
64
|
+
throw e;
|
|
65
|
+
const raced = readReportToken(nemaHome);
|
|
66
|
+
if (raced === null)
|
|
67
|
+
throw e;
|
|
68
|
+
return raced;
|
|
69
|
+
}
|
|
70
|
+
return token;
|
|
71
|
+
} finally {
|
|
72
|
+
try {
|
|
73
|
+
unlinkSync(tmp);
|
|
74
|
+
} catch {
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function reportTokenHash(nemaHome) {
|
|
79
|
+
const token = readReportToken(nemaHome);
|
|
80
|
+
return token === null ? null : createHash("sha256").update(token).digest("hex");
|
|
81
|
+
}
|
|
82
|
+
export {
|
|
83
|
+
ensureReportToken,
|
|
84
|
+
reportTokenHash,
|
|
85
|
+
reportTokenPath
|
|
86
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { OwnedHelperScope } from "@nema/shared/ownedHelper";
|
|
2
|
+
export type RepoInspect = {
|
|
3
|
+
root: string;
|
|
4
|
+
gitCommonDir: string;
|
|
5
|
+
defaultBranch: string;
|
|
6
|
+
hasOrigin: boolean;
|
|
7
|
+
remoteUrl: string | null;
|
|
8
|
+
};
|
|
9
|
+
export type FileAtRef = {
|
|
10
|
+
path: string;
|
|
11
|
+
startLine: number;
|
|
12
|
+
endLine: number;
|
|
13
|
+
totalLines: number;
|
|
14
|
+
text: string;
|
|
15
|
+
truncated: boolean;
|
|
16
|
+
};
|
|
17
|
+
export type ChangedFile = {
|
|
18
|
+
status: string;
|
|
19
|
+
path: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function commandEnv(): NodeJS.ProcessEnv;
|
|
22
|
+
export declare function gitRaw(repoPath: string, args: string[], timeout?: number, ownedHelpers?: OwnedHelperScope): Promise<Buffer>;
|
|
23
|
+
export declare function gitText(repoPath: string, args: string[], timeout?: number, ownedHelpers?: OwnedHelperScope): Promise<string>;
|
|
24
|
+
/** `nemaHome` is the directory that must not contain `repoPath`, usually `~/.nema`. */
|
|
25
|
+
export declare function inspectRepo(repoPath: string, nemaHome: string, ownedHelpers?: OwnedHelperScope): Promise<RepoInspect>;
|
|
26
|
+
export declare function mainRef(repoPath: string, mainBranch: string, ownedHelpers?: OwnedHelperScope): Promise<string>;
|
|
27
|
+
export declare function fetchMain(repoPath: string, mainBranch: string, ownedHelpers?: OwnedHelperScope): Promise<{
|
|
28
|
+
ok: true;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
error: string;
|
|
32
|
+
}>;
|
|
33
|
+
export declare function revParse(repoPath: string, ref: string, ownedHelpers?: OwnedHelperScope): Promise<string>;
|
|
34
|
+
export declare function readFileAtRef(repoPath: string, ref: string, relPath: string, range?: {
|
|
35
|
+
startLine?: number;
|
|
36
|
+
endLine?: number;
|
|
37
|
+
}, ownedHelpers?: OwnedHelperScope): Promise<FileAtRef>;
|
|
38
|
+
/** The paths (normalized) that are not in the tree of `ref`. A path that is absolute or has `..` throws. */
|
|
39
|
+
export declare function missingAtRef(repoPath: string, ref: string, paths: string[], ownedHelpers?: OwnedHelperScope): Promise<string[]>;
|
|
40
|
+
/**
|
|
41
|
+
* What changed between two commits of the bound main branch. Each of `from`/`to` is either the main ref
|
|
42
|
+
* itself (`origin/<mainBranch>`, or the local branch without one) or a sha that main already contains, so
|
|
43
|
+
* a side branch or an unpushed local commit is never read.
|
|
44
|
+
*/
|
|
45
|
+
export declare function changedFiles(repoPath: string, from: string, to: string, mainBranch: string, ownedHelpers?: OwnedHelperScope): Promise<ChangedFile[]>;
|
|
46
|
+
/** The raw diff between two refs, for the daemon's own index sync. Plugin calls go through `changedFiles`. */
|
|
47
|
+
export declare function diffNameStatus(repoPath: string, from: string, to: string, ownedHelpers?: OwnedHelperScope): Promise<ChangedFile[]>;
|
|
48
|
+
export declare function exportSnapshot(repoPath: string, ref: string, destDir: string, ownedHelpers?: OwnedHelperScope): Promise<void>;
|