@synovitec/trellis 0.0.0-stage → 0.6.0-rc.2
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/CHANGELOG.md +393 -0
- package/LICENSE +190 -0
- package/README.md +206 -2
- package/SECURITY.md +104 -0
- package/bin/trellis.mjs +5 -0
- package/docs/attestation/v0.1.md +96 -0
- package/package.json +78 -4
- package/schema/trellis.config.schema.json +349 -0
- package/src/agents/adapters.mjs +155 -0
- package/src/agents/git.mjs +241 -0
- package/src/agents/glob.mjs +41 -0
- package/src/agents/hooks.mjs +780 -0
- package/src/agents/install-adapters.mjs +172 -0
- package/src/agents/install.mjs +302 -0
- package/src/agents/manifests.mjs +127 -0
- package/src/agents/rules.mjs +330 -0
- package/src/agents/shell.mjs +253 -0
- package/src/agents/state.mjs +233 -0
- package/src/agents/transcript.mjs +120 -0
- package/src/cli/main.mjs +191 -0
- package/src/commands/attest.mjs +207 -0
- package/src/commands/checkpoint-state.mjs +62 -0
- package/src/commands/doctor.mjs +98 -0
- package/src/commands/guide.mjs +62 -0
- package/src/commands/init.mjs +129 -0
- package/src/commands/key.mjs +31 -0
- package/src/commands/scan.mjs +51 -0
- package/src/commands/uninstall.mjs +36 -0
- package/src/commands/verify.mjs +509 -0
- package/src/core/abatty.mjs +25 -0
- package/src/core/agents-block.mjs +65 -0
- package/src/core/attestation.mjs +213 -0
- package/src/core/config.mjs +135 -0
- package/src/core/context.mjs +280 -0
- package/src/core/diff.mjs +189 -0
- package/src/core/evidence.mjs +167 -0
- package/src/core/mcp.mjs +51 -0
- package/src/index.mjs +11 -0
- package/src/mcp/client.mjs +164 -0
- package/src/mcp/launch.mjs +57 -0
- package/src/mcp/relay.mjs +111 -0
- package/src/mcp/server.mjs +210 -0
- package/src/mcp/trust.mjs +142 -0
- package/src/tools/registry.mjs +370 -0
- package/src/tools/roles.mjs +17 -0
- package/src/verify/deps.mjs +382 -0
- package/src/verify/gitleaks-default.toml +3211 -0
- package/src/verify/rules.mjs +163 -0
- package/src/verify/secrets.mjs +169 -0
- package/src/verify/test-commands.mjs +116 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// A change as content: the files that differ between two trees, with the blob each one has on
|
|
2
|
+
// either side. This is what every rule reads, whoever made the change and however it arrived
|
|
3
|
+
// (commit, rebase, fetch, side clone): two trees are compared, never a history walked.
|
|
4
|
+
//
|
|
5
|
+
// Every git call here either answers or throws. An empty list means "nothing changed", never
|
|
6
|
+
// "git could not tell": a check that cannot run is a finding, not a pass.
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
|
|
12
|
+
/** A git command that failed. The caller reports it; it never reads as "no change". */
|
|
13
|
+
export class GitError extends Error {
|
|
14
|
+
/** @param {string[]} args @param {string} detail */
|
|
15
|
+
constructor(args, detail) {
|
|
16
|
+
super(`git ${args.slice(0, 3).join(" ")} failed: ${detail}`);
|
|
17
|
+
this.name = "GitError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} Change
|
|
23
|
+
* @property {string} path
|
|
24
|
+
* @property {"A" | "M" | "D" | "T"} status added, modified, deleted, type changed
|
|
25
|
+
* @property {string | null} oldBlob the blob before, null when added
|
|
26
|
+
* @property {string | null} newBlob the blob after, null when deleted
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} root
|
|
31
|
+
* @param {string[]} args
|
|
32
|
+
* @param {string} [input] fed on stdin, so a long list never becomes a long command line
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function git(root, args, input) {
|
|
36
|
+
const r = spawnSync("git", args, { cwd: root, encoding: "utf8", input, maxBuffer: 256 * 1024 * 1024, windowsHide: true });
|
|
37
|
+
if (r.error) throw new GitError(args, r.error.message);
|
|
38
|
+
if (r.status !== 0) throw new GitError(args, (r.stderr || `exit ${r.status}`).trim());
|
|
39
|
+
return r.stdout;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The empty tree, in this repository's object format (SHA-1 or SHA-256). @param {string} root */
|
|
43
|
+
const emptyTree = (root) => git(root, ["hash-object", "-t", "tree", "--stdin"], "").trim();
|
|
44
|
+
|
|
45
|
+
/** @param {string} sha */
|
|
46
|
+
const zero = (sha) => /^0+$/.test(sha);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parses `--raw -z` output: `:<mode> <mode> <old> <new> <status>\0<path>\0`, per file.
|
|
50
|
+
* @param {string} out
|
|
51
|
+
* @returns {Change[]}
|
|
52
|
+
*/
|
|
53
|
+
function parseRaw(out) {
|
|
54
|
+
const parts = out.split("\0");
|
|
55
|
+
/** @type {Change[]} */
|
|
56
|
+
const changes = [];
|
|
57
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
58
|
+
const head = parts[i].replace(/^\n/, "");
|
|
59
|
+
if (!head.startsWith(":")) continue;
|
|
60
|
+
const [, , oldSha, newSha, status] = head.slice(1).split(" ");
|
|
61
|
+
const s = /** @type {Change["status"]} */ (status[0]);
|
|
62
|
+
changes.push({
|
|
63
|
+
path: parts[i + 1],
|
|
64
|
+
status: s,
|
|
65
|
+
oldBlob: s === "A" || zero(oldSha) ? null : oldSha,
|
|
66
|
+
newBlob: s === "D" ? null : newSha,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return changes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const RAW = ["--raw", "-z", "--no-renames", "--no-abbrev", "--no-ext-diff", "--no-textconv"];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A tree id for a revision or tree. Throws when it names nothing.
|
|
76
|
+
* @param {string} root
|
|
77
|
+
* @param {string} rev
|
|
78
|
+
*/
|
|
79
|
+
export const treeOf = (root, rev) => git(root, ["rev-parse", "--verify", "--quiet", `${rev}^{tree}`]).trim();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What the next commit would contain: the index against HEAD (against nothing before the first
|
|
83
|
+
* commit).
|
|
84
|
+
* @param {string} root
|
|
85
|
+
* @returns {Change[]}
|
|
86
|
+
*/
|
|
87
|
+
export function stagedDiff(root) {
|
|
88
|
+
let base;
|
|
89
|
+
try {
|
|
90
|
+
base = treeOf(root, "HEAD");
|
|
91
|
+
} catch (e) {
|
|
92
|
+
// No HEAD yet is the only failure that is not an error: a repository before its first commit.
|
|
93
|
+
if (git(root, ["rev-parse", "--is-inside-work-tree"]).trim() !== "true") throw e;
|
|
94
|
+
base = emptyTree(root);
|
|
95
|
+
}
|
|
96
|
+
const changes = parseRaw(git(root, ["diff-index", "--cached", ...RAW, base, "--"]));
|
|
97
|
+
// A merge being concluded: only what it adds beyond both sides, as commitDiff judges it.
|
|
98
|
+
const other = spawnSync("git", ["rev-parse", "-q", "--verify", "MERGE_HEAD^{commit}"], { cwd: root, encoding: "utf8", windowsHide: true });
|
|
99
|
+
if (other.status !== 0) return changes;
|
|
100
|
+
return ownChanges(changes, [parseRaw(git(root, ["diff-index", "--cached", ...RAW, other.stdout.trim(), "--"]))]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The part of a merge that is the merge's own: the files that differ from every parent (a
|
|
105
|
+
* resolved conflict, an edit made in the merge). What each side brought is judged on its own
|
|
106
|
+
* commits, which CI checks one by one.
|
|
107
|
+
* @param {Change[]} first the change against the first parent
|
|
108
|
+
* @param {Change[][]} others the change against each other parent
|
|
109
|
+
*/
|
|
110
|
+
const ownChanges = (first, others) => first.filter((c) => others.every((o) => o.some((x) => x.path === c.path)));
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The change one commit makes: against its first parent, or against nothing for a root commit.
|
|
114
|
+
* A merge is judged by its own part only (`ownChanges`).
|
|
115
|
+
* @param {string} root
|
|
116
|
+
* @param {string} commit
|
|
117
|
+
* @returns {Change[]}
|
|
118
|
+
*/
|
|
119
|
+
export function commitDiff(root, commit) {
|
|
120
|
+
const sha = git(root, ["rev-parse", "--verify", "--quiet", `${commit}^{commit}`]).trim();
|
|
121
|
+
const [, firstParent, ...others] = git(root, ["rev-list", "--parents", "-n", "1", sha]).trim().split(" ");
|
|
122
|
+
const against = (/** @type {string} */ base) => parseRaw(git(root, ["diff-tree", "-r", ...RAW, base, sha, "--"]));
|
|
123
|
+
const changes = against(firstParent ?? emptyTree(root));
|
|
124
|
+
return others.length ? ownChanges(changes, others.map(against)) : changes;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The change from one tree to another, whatever lies between them.
|
|
129
|
+
* @param {string} root @param {string} from @param {string} to
|
|
130
|
+
* @returns {Change[]}
|
|
131
|
+
*/
|
|
132
|
+
export const treeDiff = (root, from, to) => parseRaw(git(root, ["diff-tree", "-r", ...RAW, from, to, "--"]));
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The tree the work tree holds now, as `git add -A` and a commit would make it (ignored files
|
|
136
|
+
* left out), written through a copy of the index so the real one is untouched and files git has
|
|
137
|
+
* already hashed are not hashed again.
|
|
138
|
+
* @param {string} root
|
|
139
|
+
* @returns {string}
|
|
140
|
+
*/
|
|
141
|
+
export function worktreeTree(root) {
|
|
142
|
+
const dir = mkdtempSync(join(tmpdir(), "trellis-tree-"));
|
|
143
|
+
try {
|
|
144
|
+
const index = join(dir, "index");
|
|
145
|
+
const real = resolve(root, git(root, ["rev-parse", "--git-path", "index"]).trim());
|
|
146
|
+
if (existsSync(real)) copyFileSync(real, index);
|
|
147
|
+
const env = { ...process.env, GIT_INDEX_FILE: index };
|
|
148
|
+
const run = (/** @type {string[]} */ args) => {
|
|
149
|
+
const r = spawnSync("git", args, { cwd: root, encoding: "utf8", env, maxBuffer: 64 * 1024 * 1024, windowsHide: true });
|
|
150
|
+
if (r.status !== 0) throw new GitError(args, (r.stderr || `exit ${r.status}`).trim());
|
|
151
|
+
return r.stdout.trim();
|
|
152
|
+
};
|
|
153
|
+
if (!existsSync(real)) run(["read-tree", "--empty"]);
|
|
154
|
+
run(["add", "-A", "--", "."]);
|
|
155
|
+
return run(["write-tree"]);
|
|
156
|
+
} finally {
|
|
157
|
+
rmSync(dir, { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A commit's parents. @param {string} root @param {string} commit */
|
|
162
|
+
export const parentsOf = (root, commit) => git(root, ["rev-list", "--parents", "-n", "1", commit]).trim().split(" ").slice(1);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The work tree against a recorded tree: tracked files as they are on disk, and untracked files
|
|
166
|
+
* git does not ignore. A file an ignore rule hides is not part of the change: it is not
|
|
167
|
+
* committed, so the commit checkpoint and CI never ship it either.
|
|
168
|
+
* @param {string} root
|
|
169
|
+
* @param {string} base a tree id (as `treeOf` gives it)
|
|
170
|
+
* @returns {Change[]}
|
|
171
|
+
*/
|
|
172
|
+
export function worktreeDiff(root, base) {
|
|
173
|
+
if (git(root, ["cat-file", "-t", base]).trim() !== "tree") throw new GitError(["cat-file", "-t", base], "not a tree");
|
|
174
|
+
const tracked = parseRaw(git(root, ["diff-index", ...RAW, base, "--"]));
|
|
175
|
+
const known = new Set(tracked.map((c) => c.path));
|
|
176
|
+
const untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z"])
|
|
177
|
+
.split("\0")
|
|
178
|
+
.filter((p) => p && !known.has(p))
|
|
179
|
+
.map((path) => /** @type {Change} */ ({ path, status: "A", oldBlob: null, newBlob: null }));
|
|
180
|
+
const changes = [...tracked, ...untracked];
|
|
181
|
+
// git leaves a work-tree file it has not hashed as 000...; hash those as `git add` would.
|
|
182
|
+
const unhashed = changes.filter((c) => c.status !== "D" && (c.newBlob == null || zero(c.newBlob)));
|
|
183
|
+
if (unhashed.length) {
|
|
184
|
+
const ids = git(root, ["hash-object", "--stdin-paths"], `${unhashed.map((c) => c.path).join("\n")}\n`).trim().split(/\r?\n/);
|
|
185
|
+
if (ids.length !== unhashed.length) throw new GitError(["hash-object", "--stdin-paths"], "fewer ids than files");
|
|
186
|
+
unhashed.forEach((c, i) => (c.newBlob = ids[i]));
|
|
187
|
+
}
|
|
188
|
+
return changes;
|
|
189
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// The evidence log: one append-only file per repository, written by whatever ran a check (the
|
|
2
|
+
// Trellis gateway, the `trellis` CLI, an agent hook) and read at the commit checkpoint. Each line
|
|
3
|
+
// is signed and chained to the one before it by its hash, so an edited, removed or reordered line
|
|
4
|
+
// reads as tampering, never as fewer events. A signed checkpoint marks the line the last commit
|
|
5
|
+
// stopped at; a verification reads the events after it.
|
|
6
|
+
//
|
|
7
|
+
// Same-user limit: the key is the user's, so an agent running as that user can forge a line with
|
|
8
|
+
// enough effort. Locally this is integrity, not proof; CI re-runs the deterministic checks.
|
|
9
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { sign, stateDir, testOverride, valid } from "../agents/state.mjs";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} Event
|
|
16
|
+
* @property {string} kind graph, docs, schema, browser, tests, review, plan, approval, escalation, agent (an agent at work)
|
|
17
|
+
* @property {string} tool what ran it (graphify, context7, npm test...)
|
|
18
|
+
* @property {string} agent who asked for it: an agent's id, or "person"
|
|
19
|
+
* @property {string} [subject] what it was about (a query, package names, a URL), as a digest when private
|
|
20
|
+
* @property {Record<string, string>} [blobs] path -> blob id of each file it checked, as it was then
|
|
21
|
+
* @property {string} [tree] the whole tree a test run ran on, when the run left it unchanged
|
|
22
|
+
* @property {number} [result] an exit code, for checks that have one
|
|
23
|
+
* @property {number} [at] when it was recorded (ms), set by appendEvent
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The evidence folder: the test override, else the git folder's `trellis`. @param {string} root @param {NodeJS.ProcessEnv} env */
|
|
27
|
+
const dir = (root, env) => testOverride(env, "TRELLIS_STATE_DIR") ?? join(stateDir(root, env), "..");
|
|
28
|
+
|
|
29
|
+
/** @param {string} root @param {NodeJS.ProcessEnv} [env] */
|
|
30
|
+
export const evidenceFile = (root, env = process.env) => join(dir(root, env), "evidence.jsonl");
|
|
31
|
+
/** @param {string} root @param {NodeJS.ProcessEnv} env */
|
|
32
|
+
const checkpointFile = (root, env) => join(dir(root, env), "checkpoint.json");
|
|
33
|
+
|
|
34
|
+
/** @param {string} line */
|
|
35
|
+
const hashOf = (line) => createHash("sha256").update(line).digest("hex");
|
|
36
|
+
|
|
37
|
+
/** @param {string} file */
|
|
38
|
+
const linesOf = (file) => (existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(Boolean) : []);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Appends one event, signed and chained to the last line.
|
|
42
|
+
* @param {string} root
|
|
43
|
+
* @param {Event} event
|
|
44
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
45
|
+
*/
|
|
46
|
+
export function appendEvent(root, event, env = process.env) {
|
|
47
|
+
const file = evidenceFile(root, env);
|
|
48
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
49
|
+
// The gateway, the hooks and parallel tool calls write at once: reading the last line and
|
|
50
|
+
// appending after it must be one step, or two writers chain to the same line and the log reads
|
|
51
|
+
// as tampered for good.
|
|
52
|
+
withLock(`${file}.lock`, () => {
|
|
53
|
+
const lines = linesOf(file);
|
|
54
|
+
const prev = lines.length ? hashOf(lines[lines.length - 1]) : "";
|
|
55
|
+
const e = { ...event, at: Date.now() };
|
|
56
|
+
const body = JSON.stringify({ e, prev });
|
|
57
|
+
appendFileSync(file, `${JSON.stringify({ e, prev, sig: sign(body, env) })}\n`);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A lock older than this was left by a writer that died holding it. */
|
|
62
|
+
const STALE_LOCK_MS = 10_000;
|
|
63
|
+
const LOCK_WAIT_MS = 15_000;
|
|
64
|
+
const HELD = new Set(["EEXIST", "EPERM", "EBUSY", "EACCES"]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Runs `fn` holding an exclusive lock file. Creating a file with `wx` is atomic on every platform.
|
|
68
|
+
* Waits for a writer holding it, takes over a lock left by a writer that died, and throws rather
|
|
69
|
+
* than write unlocked.
|
|
70
|
+
* @param {string} lock
|
|
71
|
+
* @param {() => void} fn
|
|
72
|
+
*/
|
|
73
|
+
function withLock(lock, fn) {
|
|
74
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
75
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
76
|
+
for (;;) {
|
|
77
|
+
try {
|
|
78
|
+
closeSync(openSync(lock, "wx"));
|
|
79
|
+
break;
|
|
80
|
+
} catch (e) {
|
|
81
|
+
// Windows answers EPERM or EBUSY, not EEXIST, while another writer's lock is being removed.
|
|
82
|
+
if (!HELD.has(String(/** @type {NodeJS.ErrnoException} */ (e).code))) throw e;
|
|
83
|
+
try {
|
|
84
|
+
if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) rmSync(lock, { force: true });
|
|
85
|
+
} catch {
|
|
86
|
+
/* released meanwhile */
|
|
87
|
+
}
|
|
88
|
+
if (Date.now() > deadline) throw new Error(`the evidence log stayed locked (${lock}); remove the file if no Trellis is running`);
|
|
89
|
+
Atomics.wait(sleeper, 0, 0, 5 + Math.floor(Math.random() * 20));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
fn();
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(lock, { force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The events since the last checkpoint, or "tampered" when any line, the chain or the checkpoint
|
|
101
|
+
* does not verify. A log that does not exist is no evidence, which every rule that needs some
|
|
102
|
+
* reads as missing.
|
|
103
|
+
* @param {string} root
|
|
104
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
105
|
+
* @returns {{ status: "ok" | "tampered", events: Event[] }}
|
|
106
|
+
*/
|
|
107
|
+
export function readWindow(root, env = process.env) {
|
|
108
|
+
const lines = linesOf(evidenceFile(root, env));
|
|
109
|
+
/** @type {Event[]} */
|
|
110
|
+
const events = [];
|
|
111
|
+
let prev = "";
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
let rec;
|
|
114
|
+
try {
|
|
115
|
+
rec = JSON.parse(line);
|
|
116
|
+
} catch {
|
|
117
|
+
return { status: "tampered", events: [] };
|
|
118
|
+
}
|
|
119
|
+
if (rec?.prev !== prev || !valid(rec?.sig, JSON.stringify({ e: rec?.e, prev: rec?.prev }), env)) return { status: "tampered", events: [] };
|
|
120
|
+
events.push(rec.e);
|
|
121
|
+
prev = hashOf(line);
|
|
122
|
+
}
|
|
123
|
+
let from = 0;
|
|
124
|
+
const cp = checkpointFile(root, env);
|
|
125
|
+
if (existsSync(cp)) {
|
|
126
|
+
let c;
|
|
127
|
+
try {
|
|
128
|
+
c = JSON.parse(readFileSync(cp, "utf8"));
|
|
129
|
+
} catch {
|
|
130
|
+
return { status: "tampered", events: [] };
|
|
131
|
+
}
|
|
132
|
+
if (!Number.isInteger(c?.line) || c.line > lines.length || !valid(c?.sig, `checkpoint:${c?.line}`, env)) return { status: "tampered", events: [] };
|
|
133
|
+
from = c.line;
|
|
134
|
+
}
|
|
135
|
+
return { status: "ok", events: events.slice(from) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Marks the end of the log as the start of the next window, after a commit.
|
|
140
|
+
* @param {string} root
|
|
141
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
142
|
+
*/
|
|
143
|
+
export function checkpoint(root, env = process.env) {
|
|
144
|
+
const line = linesOf(evidenceFile(root, env)).length;
|
|
145
|
+
mkdirSync(dir(root, env), { recursive: true });
|
|
146
|
+
writeFileSync(checkpointFile(root, env), JSON.stringify({ line, sig: sign(`checkpoint:${line}`, env) }));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Sets the log and its checkpoint aside, kept under a dated name, and starts a new one: the way
|
|
151
|
+
* out of a log that no longer verifies. A person's step; what was set aside is never deleted.
|
|
152
|
+
* @param {string} root
|
|
153
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
154
|
+
* @returns {string | null} the name the old log was kept under, or null when there was none
|
|
155
|
+
*/
|
|
156
|
+
export function resetEvidence(root, env = process.env) {
|
|
157
|
+
const file = evidenceFile(root, env);
|
|
158
|
+
let kept = null;
|
|
159
|
+
withLock(`${file}.lock`, () => {
|
|
160
|
+
if (existsSync(file)) {
|
|
161
|
+
kept = file.replace(/\.jsonl$/, `.${new Date().toISOString().replace(/[:.]/g, "-")}.set-aside.jsonl`);
|
|
162
|
+
renameSync(file, kept);
|
|
163
|
+
}
|
|
164
|
+
rmSync(checkpointFile(root, env), { force: true });
|
|
165
|
+
});
|
|
166
|
+
return kept;
|
|
167
|
+
}
|
package/src/core/mcp.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Merging MCP servers into .mcp.json. Additive only: an entry the repository already has for the
|
|
2
|
+
// same tool is left exactly as it is, because a person may have tuned it on purpose.
|
|
3
|
+
import { mcpServers } from "../tools/registry.mjs";
|
|
4
|
+
import { parseJson } from "./context.mjs";
|
|
5
|
+
|
|
6
|
+
/** @typedef {import("./context.mjs").Context} Context */
|
|
7
|
+
/** @typedef {import("../tools/registry.mjs").Tool} Tool */
|
|
8
|
+
|
|
9
|
+
/** How each tool's entry is recognised in an existing file, whatever its key is called. */
|
|
10
|
+
const MATCHERS = /** @type {Record<string, RegExp>} */ ({
|
|
11
|
+
context7: /context7-mcp/,
|
|
12
|
+
"postgres-mcp": /postgres-mcp/,
|
|
13
|
+
"playwright-mcp": /@playwright\/mcp/,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** The key a new entry is written under. */
|
|
17
|
+
const KEYS = /** @type {Record<string, string>} */ ({ context7: "context7", "postgres-mcp": "postgres", "playwright-mcp": "playwright" });
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The new .mcp.json text, and which entries it adds. `text` is null when nothing changes.
|
|
21
|
+
* @param {Context} ctx
|
|
22
|
+
* @param {Array<{ tool: Tool, opts: Record<string, any> }>} enabled
|
|
23
|
+
* @param {{ type: string, command: string, args: string[] } | null} [gateway] the Trellis server's entry, when Trellis is installed
|
|
24
|
+
* @returns {{ text: string | null, added: string[], kept: string[] }}
|
|
25
|
+
*/
|
|
26
|
+
export function planMcp(ctx, enabled, gateway = null) {
|
|
27
|
+
const raw = ctx.read(".mcp.json");
|
|
28
|
+
const current = parseJson(raw);
|
|
29
|
+
if (raw != null && current == null) throw new Error(".mcp.json is not valid JSON; fix it before trellis writes to it");
|
|
30
|
+
const doc = current ?? {};
|
|
31
|
+
doc.mcpServers ??= {};
|
|
32
|
+
const added = [];
|
|
33
|
+
const kept = [];
|
|
34
|
+
for (const { tool, opts } of enabled) {
|
|
35
|
+
if (!tool.mcp) continue;
|
|
36
|
+
if (mcpServers(ctx, MATCHERS[tool.id]).length) {
|
|
37
|
+
kept.push(tool.id);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
let key = KEYS[tool.id] ?? tool.id;
|
|
41
|
+
if (doc.mcpServers[key]) key = `${key}-trellis`;
|
|
42
|
+
doc.mcpServers[key] = tool.mcp(opts);
|
|
43
|
+
added.push(tool.id);
|
|
44
|
+
}
|
|
45
|
+
// Trellis's own server: the checks as tools for any agent. Kept as it is if already declared.
|
|
46
|
+
if (gateway && !Object.values(doc.mcpServers).some((s) => /trellis(\.mjs)?["']?,?\s*"?mcp\b/.test(JSON.stringify([s?.command, ...(s?.args ?? [])])))) {
|
|
47
|
+
doc.mcpServers[doc.mcpServers.trellis ? "trellis-gateway" : "trellis"] = gateway;
|
|
48
|
+
added.push("trellis");
|
|
49
|
+
}
|
|
50
|
+
return { text: added.length ? `${JSON.stringify(doc, null, 2)}\n` : null, added, kept };
|
|
51
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createContext } from "./core/context.mjs";
|
|
2
|
+
export { CONFIG_FILE, defaultConfig, enabledTools, loadConfig, validateConfig } from "./core/config.mjs";
|
|
3
|
+
export { planMcp } from "./core/mcp.mjs";
|
|
4
|
+
export { renderBlock, upsertBlock } from "./core/agents-block.mjs";
|
|
5
|
+
export { TOOLS, toolById, installHint, mcpServers } from "./tools/registry.mjs";
|
|
6
|
+
export { ROLES, roleById } from "./tools/roles.mjs";
|
|
7
|
+
export { diagnose, doctor } from "./commands/doctor.mjs";
|
|
8
|
+
export { planInit, init } from "./commands/init.mjs";
|
|
9
|
+
export { guide } from "./commands/guide.mjs";
|
|
10
|
+
export { scan } from "./commands/scan.mjs";
|
|
11
|
+
export { main } from "./cli/main.mjs";
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Trellis as an MCP client of the servers it relays, over stdio, either era (specification
|
|
2
|
+
// 2026-07-28, stdio "Backward Compatibility"): probe with server/discover; a discover result or a
|
|
3
|
+
// recognised modern error means a modern server, anything else (or no answer) a legacy one, which
|
|
4
|
+
// gets the initialize handshake.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import { VERSION } from "../core/config.mjs";
|
|
8
|
+
import { launchSpec } from "./launch.mjs";
|
|
9
|
+
|
|
10
|
+
const MODERN = "2026-07-28";
|
|
11
|
+
const LEGACY = "2025-11-25";
|
|
12
|
+
/** Errors only a modern server sends: UnsupportedProtocolVersion, HeaderMismatch, MissingRequiredClientCapability. */
|
|
13
|
+
const MODERN_ERRORS = new Set([-32022, -32020, -32021]);
|
|
14
|
+
const CLIENT = { name: "trellis", version: VERSION };
|
|
15
|
+
|
|
16
|
+
/** An error a server answered with. */
|
|
17
|
+
export class RpcError extends Error {
|
|
18
|
+
/** @param {{ code: number, message: string, data?: any }} e */
|
|
19
|
+
constructor(e) {
|
|
20
|
+
super(e.message);
|
|
21
|
+
this.code = e.code;
|
|
22
|
+
this.data = e.data;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `${VAR}` in a server entry, from the environment, as Claude Code expands it. @param {any} v @param {NodeJS.ProcessEnv} env */
|
|
27
|
+
const expand = (v, env) => (typeof v === "string" ? v.replace(/\$\{(\w+)\}/g, (_, name) => env[name] ?? "") : v);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One relayed server: launched on first use, its era detected once.
|
|
31
|
+
*/
|
|
32
|
+
export class Upstream {
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} name
|
|
35
|
+
* @param {{ command: string, args?: string[], env?: Record<string, string>, cwd?: string }} entry
|
|
36
|
+
* @param {{ cwd: string, env: NodeJS.ProcessEnv }} where
|
|
37
|
+
*/
|
|
38
|
+
constructor(name, entry, where) {
|
|
39
|
+
this.name = name;
|
|
40
|
+
this.entry = entry;
|
|
41
|
+
this.where = where;
|
|
42
|
+
/** @type {"modern" | "legacy" | null} */
|
|
43
|
+
this.era = null;
|
|
44
|
+
this.version = MODERN;
|
|
45
|
+
/** @type {Map<number, { resolve: (v: any) => void, reject: (e: Error) => void, timer: NodeJS.Timeout }>} */
|
|
46
|
+
this.pending = new Map();
|
|
47
|
+
this.next = 1;
|
|
48
|
+
/** @type {Promise<void> | null} */
|
|
49
|
+
this.ready = null;
|
|
50
|
+
/** @type {import("node:child_process").ChildProcess | null} */
|
|
51
|
+
this.child = null;
|
|
52
|
+
/** @type {Error | null} why the server is gone, once it is: every later request fails at once */
|
|
53
|
+
this.gone = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Launches the server and settles its era; once. */
|
|
57
|
+
start() {
|
|
58
|
+
this.ready ??= this.#launch();
|
|
59
|
+
return this.ready;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async #launch() {
|
|
63
|
+
const env = { ...this.where.env, ...Object.fromEntries(Object.entries(this.entry.env ?? {}).map(([k, v]) => [k, expand(v, this.where.env)])) };
|
|
64
|
+
const command = expand(this.entry.command, this.where.env);
|
|
65
|
+
const args = (this.entry.args ?? []).map((a) => expand(a, this.where.env));
|
|
66
|
+
// `npx` and the like are .cmd shims on Windows: ./launch.mjs gets their arguments through cmd.exe intact.
|
|
67
|
+
const spec = launchSpec(command, args, env);
|
|
68
|
+
const child = spawn(spec.file, spec.args, { cwd: this.entry.cwd ?? this.where.cwd, env, stdio: ["pipe", "pipe", "pipe"], shell: spec.shell, windowsVerbatimArguments: spec.windowsVerbatimArguments, windowsHide: true });
|
|
69
|
+
this.child = child;
|
|
70
|
+
const end = (/** @type {Error} */ e) => {
|
|
71
|
+
this.gone ??= e;
|
|
72
|
+
this.#failAll(e);
|
|
73
|
+
};
|
|
74
|
+
child.on("error", end);
|
|
75
|
+
child.on("exit", (code) => end(new Error(`${this.name} exited (code ${code})`)));
|
|
76
|
+
child.stderr?.on("data", () => {});
|
|
77
|
+
createInterface({ input: /** @type {NodeJS.ReadableStream} */ (child.stdout) }).on("line", (line) => this.#receive(line));
|
|
78
|
+
try {
|
|
79
|
+
const discover = await this.#send("server/discover", { _meta: this.#meta() }, 5_000);
|
|
80
|
+
const supported = discover?.supportedVersions ?? [];
|
|
81
|
+
this.era = "modern";
|
|
82
|
+
if (supported.length && !supported.includes(MODERN)) throw new Error(`${this.name} supports ${supported.join(", ")}, not ${MODERN}`);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
if (e instanceof RpcError && MODERN_ERRORS.has(/** @type {number} */ (e.code))) {
|
|
85
|
+
this.era = "modern";
|
|
86
|
+
const supported = e.data?.supported ?? [];
|
|
87
|
+
if (!supported.includes(MODERN)) throw new Error(`${this.name} supports ${supported.join(", ")}, not ${MODERN}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (this.era === "modern") throw e;
|
|
91
|
+
// Anything else, an unknown-method error or silence, is a legacy server.
|
|
92
|
+
this.era = "legacy";
|
|
93
|
+
const init = await this.#send("initialize", { protocolVersion: LEGACY, capabilities: {}, clientInfo: CLIENT }, 60_000);
|
|
94
|
+
this.version = String(init?.protocolVersion ?? LEGACY);
|
|
95
|
+
this.#notify("notifications/initialized");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#meta() {
|
|
100
|
+
return { "io.modelcontextprotocol/protocolVersion": MODERN, "io.modelcontextprotocol/clientInfo": CLIENT, "io.modelcontextprotocol/clientCapabilities": {} };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @param {string} line */
|
|
104
|
+
#receive(line) {
|
|
105
|
+
let msg;
|
|
106
|
+
try {
|
|
107
|
+
msg = JSON.parse(line);
|
|
108
|
+
} catch {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const p = this.pending.get(msg?.id);
|
|
112
|
+
if (!p) return;
|
|
113
|
+
this.pending.delete(msg.id);
|
|
114
|
+
clearTimeout(p.timer);
|
|
115
|
+
if (msg.error) p.reject(new RpcError(msg.error));
|
|
116
|
+
else p.resolve(msg.result);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @param {Error} e */
|
|
120
|
+
#failAll(e) {
|
|
121
|
+
for (const p of this.pending.values()) {
|
|
122
|
+
clearTimeout(p.timer);
|
|
123
|
+
p.reject(e);
|
|
124
|
+
}
|
|
125
|
+
this.pending.clear();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @param {string} method @param {any} params @param {number} timeoutMs */
|
|
129
|
+
#send(method, params, timeoutMs) {
|
|
130
|
+
if (this.gone) return Promise.reject(this.gone);
|
|
131
|
+
const id = this.next++;
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
this.pending.delete(id);
|
|
135
|
+
reject(new Error(`${this.name}: no answer to ${method} within ${timeoutMs / 1000} s`));
|
|
136
|
+
}, timeoutMs);
|
|
137
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
138
|
+
this.child?.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @param {string} method */
|
|
143
|
+
#notify(method) {
|
|
144
|
+
this.child?.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", method })}\n`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A request in the server's era: modern ones carry their `_meta`.
|
|
149
|
+
* @param {string} method
|
|
150
|
+
* @param {Record<string, any>} [params]
|
|
151
|
+
* @param {number} [timeoutMs]
|
|
152
|
+
*/
|
|
153
|
+
async request(method, params = {}, timeoutMs = 120_000) {
|
|
154
|
+
await this.start();
|
|
155
|
+
const p = this.era === "modern" ? { ...params, _meta: { ...(params._meta ?? {}), ...this.#meta() } } : params;
|
|
156
|
+
return this.#send(method, p, timeoutMs);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
close() {
|
|
160
|
+
this.#failAll(new Error(`${this.name} closed`));
|
|
161
|
+
this.child?.stdin?.end();
|
|
162
|
+
this.child?.kill();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// How a relayed server's command line reaches the operating system unchanged. On Windows, a
|
|
2
|
+
// command named without a path (`npx`, `uvx`) is looked up on PATH as Windows does: an `.exe` is
|
|
3
|
+
// started directly, its arguments quoted by Node; a `.cmd` or `.bat` shim can only run through
|
|
4
|
+
// cmd.exe, which reads `"`, `%`, `&`, `^` and the like itself, so each argument is escaped for it,
|
|
5
|
+
// twice for a shim (cmd reads the line once, the shim passes it on once more), the way cross-spawn
|
|
6
|
+
// does. Elsewhere the command and its arguments go to the system as they are.
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { delimiter, extname, join } from "node:path";
|
|
9
|
+
|
|
10
|
+
const META = /([()\][%!^"`<>&|;, *?])/g;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One argument for cmd.exe: backslashes before a quote doubled, quoted, cmd's characters escaped.
|
|
14
|
+
* @param {string} arg @param {boolean} shim
|
|
15
|
+
*/
|
|
16
|
+
function cmdArg(arg, shim) {
|
|
17
|
+
let a = String(arg)
|
|
18
|
+
.replace(/(\\*)"/g, '$1$1\\"')
|
|
19
|
+
.replace(/(\\*)$/, "$1$1");
|
|
20
|
+
a = `"${a}"`.replace(META, "^$1");
|
|
21
|
+
return shim ? a.replace(META, "^$1") : a;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Where Windows finds `command`, by PATH and PATHEXT, or null.
|
|
26
|
+
* @param {string} command @param {NodeJS.ProcessEnv} env
|
|
27
|
+
*/
|
|
28
|
+
function onPath(command, env) {
|
|
29
|
+
const exts = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
|
|
30
|
+
const dirs = (env.PATH ?? env.Path ?? "").split(delimiter).filter(Boolean);
|
|
31
|
+
for (const dir of dirs) {
|
|
32
|
+
for (const ext of extname(command) ? [""] : exts) {
|
|
33
|
+
const file = join(dir, command + ext);
|
|
34
|
+
if (existsSync(file)) return file;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What to hand to spawn for `command` with `args`.
|
|
42
|
+
* @param {string} command
|
|
43
|
+
* @param {string[]} args
|
|
44
|
+
* @param {NodeJS.ProcessEnv} env
|
|
45
|
+
* @param {NodeJS.Platform} [platform]
|
|
46
|
+
* @returns {{ file: string, args: string[], shell: boolean, windowsVerbatimArguments: boolean }}
|
|
47
|
+
*/
|
|
48
|
+
export function launchSpec(command, args, env, platform = process.platform) {
|
|
49
|
+
if (platform !== "win32") return { file: command, args, shell: false, windowsVerbatimArguments: false };
|
|
50
|
+
const found = /[\\/]/.test(command) ? command : (onPath(command, env) ?? command);
|
|
51
|
+
if (!/\.(cmd|bat)$/i.test(found)) return { file: found, args, shell: false, windowsVerbatimArguments: false };
|
|
52
|
+
// cmd.exe ends a command at a line break, whatever the quoting: such an argument would arrive cut.
|
|
53
|
+
if (args.some((a) => /[\r\n]/.test(a))) throw new Error(`an argument holds a line break, which cmd.exe cannot pass to ${command}; start it through node or an .exe instead`);
|
|
54
|
+
// cmd.exe /d /s /c "<line>": the line is passed as it is, each part escaped above.
|
|
55
|
+
const line = [found.replace(META, "^$1"), ...args.map((a) => cmdArg(a, true))].join(" ");
|
|
56
|
+
return { file: env.ComSpec ?? env.COMSPEC ?? "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], shell: false, windowsVerbatimArguments: true };
|
|
57
|
+
}
|