agent-trellis 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -17
- package/dist/adapters/claude-code.d.ts +2 -1
- package/dist/adapters/claude-code.js +7 -7
- package/dist/adapters/codex.d.ts +2 -1
- package/dist/adapters/codex.js +7 -7
- package/dist/adapters/jsonMcp.d.ts +1 -0
- package/dist/adapters/jsonMcp.js +2 -2
- package/dist/adapters/kiro.d.ts +2 -1
- package/dist/adapters/kiro.js +9 -9
- package/dist/adapters/mcpPlan.d.ts +1 -1
- package/dist/adapters/mcpPlan.js +2 -2
- package/dist/adapters/pi.d.ts +2 -1
- package/dist/adapters/pi.js +4 -4
- package/dist/adapters/symlinkPlan.d.ts +7 -3
- package/dist/adapters/symlinkPlan.js +42 -16
- package/dist/cli.js +41 -11
- package/dist/commands/init.js +11 -0
- package/dist/commands/mcp.d.ts +13 -0
- package/dist/commands/mcp.js +31 -7
- package/dist/commands/onboard.d.ts +42 -7
- package/dist/commands/onboard.js +207 -34
- package/dist/commands/rollback.d.ts +44 -0
- package/dist/commands/rollback.js +201 -0
- package/dist/commands/secretsAudit.d.ts +7 -0
- package/dist/commands/secretsAudit.js +14 -7
- package/dist/commands/sync.d.ts +13 -0
- package/dist/commands/sync.js +31 -5
- package/dist/core/adapter.d.ts +10 -3
- package/dist/core/adapter.js +2 -2
- package/dist/core/canonical.js +22 -0
- package/dist/core/types.d.ts +12 -1
- package/dist/core/types.js +11 -2
- package/dist/lib/backup.d.ts +56 -0
- package/dist/lib/backup.js +98 -0
- package/dist/lib/installAgent.d.ts +26 -0
- package/dist/lib/installAgent.js +46 -0
- package/dist/pi-bridge/bundle.js +26 -7
- package/dist/pi-bridge/index.js +8 -1
- package/docs/getting-started.md +104 -26
- package/docs/roadmap.md +133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `trellis rollback` — restores exactly what one recorded backup run
|
|
3
|
+
* changed (trellis-backup-rollback). Every operation is checked against
|
|
4
|
+
* its target path's *current* state before touching anything: if the
|
|
5
|
+
* path still matches what the run itself left behind, it's restored; if
|
|
6
|
+
* something else has touched it since, that's a conflict, reported and
|
|
7
|
+
* left untouched — same "verify, never guess" posture `sync`/`mcp sync`
|
|
8
|
+
* already hold themselves to for every other kind of conflict.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { readlink, rm, symlink } from "node:fs/promises";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { backupsRoot } from "../lib/backup.js";
|
|
16
|
+
function sha256(content) {
|
|
17
|
+
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
18
|
+
}
|
|
19
|
+
/** Newest first — run ids are ISO-timestamp-prefixed, so lexical sort is
|
|
20
|
+
* chronological sort. */
|
|
21
|
+
export function listBackups(homeDir = homedir()) {
|
|
22
|
+
const root = backupsRoot(homeDir);
|
|
23
|
+
if (!existsSync(root))
|
|
24
|
+
return [];
|
|
25
|
+
const runIds = readdirSync(root).sort().reverse();
|
|
26
|
+
const summaries = [];
|
|
27
|
+
for (const runId of runIds) {
|
|
28
|
+
const manifest = tryLoadManifest(homeDir, runId);
|
|
29
|
+
if (!manifest)
|
|
30
|
+
continue;
|
|
31
|
+
summaries.push({ runId, command: manifest.command, startedAt: manifest.startedAt, operationCount: manifest.operations.length });
|
|
32
|
+
}
|
|
33
|
+
return summaries;
|
|
34
|
+
}
|
|
35
|
+
function tryLoadManifest(homeDir, runId) {
|
|
36
|
+
const manifestPath = join(backupsRoot(homeDir), runId, "manifest.json");
|
|
37
|
+
if (!existsSync(manifestPath))
|
|
38
|
+
return undefined;
|
|
39
|
+
return JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
40
|
+
}
|
|
41
|
+
export function loadManifest(homeDir, runId) {
|
|
42
|
+
const manifest = tryLoadManifest(homeDir, runId);
|
|
43
|
+
if (!manifest) {
|
|
44
|
+
throw new Error(`No backup run "${runId}" found under ${backupsRoot(homeDir)}`);
|
|
45
|
+
}
|
|
46
|
+
return manifest;
|
|
47
|
+
}
|
|
48
|
+
function resolveRunId(homeDir, requested) {
|
|
49
|
+
if (requested)
|
|
50
|
+
return requested;
|
|
51
|
+
const [mostRecent] = listBackups(homeDir);
|
|
52
|
+
if (!mostRecent) {
|
|
53
|
+
throw new Error(`No backup runs found under ${backupsRoot(homeDir)} — nothing to roll back`);
|
|
54
|
+
}
|
|
55
|
+
return mostRecent.runId;
|
|
56
|
+
}
|
|
57
|
+
async function currentLinkTarget(path) {
|
|
58
|
+
try {
|
|
59
|
+
return await readlink(path);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function planOperation(homeDir, runId, op) {
|
|
66
|
+
const runDir = join(backupsRoot(homeDir), runId);
|
|
67
|
+
switch (op.kind) {
|
|
68
|
+
case "file-create":
|
|
69
|
+
case "file-overwrite": {
|
|
70
|
+
if (!existsSync(op.path)) {
|
|
71
|
+
return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
|
|
72
|
+
}
|
|
73
|
+
const currentHash = sha256(readFileSync(op.path, "utf-8"));
|
|
74
|
+
if (currentHash !== op.afterHash) {
|
|
75
|
+
return { action: "conflict", path: op.path, description: `${op.path} has changed since this run — left untouched` };
|
|
76
|
+
}
|
|
77
|
+
if (op.kind === "file-create") {
|
|
78
|
+
return { action: "restore", path: op.path, description: `delete ${op.path} (created by this run)` };
|
|
79
|
+
}
|
|
80
|
+
return { action: "restore", path: op.path, description: `restore ${op.path} to its content before this run (${join(runDir, op.beforeFile)})` };
|
|
81
|
+
}
|
|
82
|
+
case "symlink-create":
|
|
83
|
+
case "symlink-repair": {
|
|
84
|
+
const current = await currentLinkTarget(op.path);
|
|
85
|
+
if (current === undefined) {
|
|
86
|
+
return { action: "already-reverted", path: op.path, description: `${op.path} no longer exists — nothing to undo` };
|
|
87
|
+
}
|
|
88
|
+
if (current !== op.afterLinkTarget) {
|
|
89
|
+
return { action: "conflict", path: op.path, description: `${op.path} points somewhere else now — left untouched` };
|
|
90
|
+
}
|
|
91
|
+
if (op.kind === "symlink-create") {
|
|
92
|
+
return { action: "restore", path: op.path, description: `remove ${op.path} (created by this run)` };
|
|
93
|
+
}
|
|
94
|
+
return { action: "restore", path: op.path, description: `repoint ${op.path} back to ${op.beforeLinkTarget}` };
|
|
95
|
+
}
|
|
96
|
+
case "symlink-remove": {
|
|
97
|
+
if (existsSync(op.path)) {
|
|
98
|
+
return { action: "conflict", path: op.path, description: `${op.path} exists again since this run — left untouched` };
|
|
99
|
+
}
|
|
100
|
+
return { action: "restore", path: op.path, description: `recreate ${op.path} -> ${op.beforeLinkTarget}` };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export async function collectRollbackPlan(homeDirInput, runIdInput) {
|
|
105
|
+
const homeDir = homeDirInput ?? homedir();
|
|
106
|
+
const runId = resolveRunId(homeDir, runIdInput);
|
|
107
|
+
const manifest = loadManifest(homeDir, runId);
|
|
108
|
+
const items = [];
|
|
109
|
+
for (const op of manifest.operations) {
|
|
110
|
+
items.push(await planOperation(homeDir, runId, op));
|
|
111
|
+
}
|
|
112
|
+
return { runId, items };
|
|
113
|
+
}
|
|
114
|
+
async function restoreOperation(homeDir, runId, op) {
|
|
115
|
+
const runDir = join(backupsRoot(homeDir), runId);
|
|
116
|
+
switch (op.kind) {
|
|
117
|
+
case "file-create":
|
|
118
|
+
rmSync(op.path, { force: true });
|
|
119
|
+
return;
|
|
120
|
+
case "file-overwrite":
|
|
121
|
+
writeFileSync(op.path, readFileSync(join(runDir, op.beforeFile), "utf-8"));
|
|
122
|
+
return;
|
|
123
|
+
case "symlink-create":
|
|
124
|
+
await rm(op.path, { force: true });
|
|
125
|
+
return;
|
|
126
|
+
case "symlink-repair":
|
|
127
|
+
await rm(op.path, { force: true });
|
|
128
|
+
await symlink(op.beforeLinkTarget, op.path);
|
|
129
|
+
return;
|
|
130
|
+
case "symlink-remove":
|
|
131
|
+
await symlink(op.beforeLinkTarget, op.path);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
export async function applyRollbackPlan(homeDir, runId, manifest, items) {
|
|
136
|
+
const restorePaths = new Set(items.filter((i) => i.action === "restore").map((i) => i.path));
|
|
137
|
+
for (const op of manifest.operations) {
|
|
138
|
+
if (!restorePaths.has(op.path))
|
|
139
|
+
continue;
|
|
140
|
+
await restoreOperation(homeDir, runId, op);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export async function runRollback(opts = {}) {
|
|
144
|
+
const homeDir = opts.homeDir ?? homedir();
|
|
145
|
+
if (opts.list) {
|
|
146
|
+
const runs = listBackups(homeDir);
|
|
147
|
+
if (opts.json) {
|
|
148
|
+
console.log(JSON.stringify(runs, null, 2));
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
printList(runs);
|
|
152
|
+
}
|
|
153
|
+
return { exitCode: 0 };
|
|
154
|
+
}
|
|
155
|
+
let report;
|
|
156
|
+
try {
|
|
157
|
+
report = await collectRollbackPlan(homeDir, opts.runId);
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
161
|
+
return { exitCode: 1 };
|
|
162
|
+
}
|
|
163
|
+
if (!opts.dryRun) {
|
|
164
|
+
const manifest = loadManifest(homeDir, report.runId);
|
|
165
|
+
await applyRollbackPlan(homeDir, report.runId, manifest, report.items);
|
|
166
|
+
}
|
|
167
|
+
if (opts.json) {
|
|
168
|
+
console.log(JSON.stringify(report, null, 2));
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
printReport(report, opts.dryRun ?? false);
|
|
172
|
+
}
|
|
173
|
+
const hasConflict = report.items.some((i) => i.action === "conflict");
|
|
174
|
+
return { exitCode: hasConflict ? 1 : 0 };
|
|
175
|
+
}
|
|
176
|
+
function printList(runs) {
|
|
177
|
+
if (runs.length === 0) {
|
|
178
|
+
console.log("No backup runs found under ~/.trellis/backups/.");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
for (const run of runs) {
|
|
182
|
+
console.log(`${run.runId} — ${run.command}, ${run.operationCount} operation(s), ${run.startedAt}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
export function printReport(report, dryRun) {
|
|
186
|
+
if (dryRun)
|
|
187
|
+
console.log("[dry run]");
|
|
188
|
+
console.log(`rollback ${report.runId}`);
|
|
189
|
+
const restored = report.items.filter((i) => i.action === "restore");
|
|
190
|
+
const conflicts = report.items.filter((i) => i.action === "conflict");
|
|
191
|
+
const alreadyReverted = report.items.filter((i) => i.action === "already-reverted");
|
|
192
|
+
if (report.items.length === 0) {
|
|
193
|
+
console.log(" nothing recorded in this run");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const icon = conflicts.length > 0 ? "⚠️ " : "✅";
|
|
197
|
+
console.log(`${icon} ${restored.length} restored, ${conflicts.length} conflict(s), ${alreadyReverted.length} already reverted`);
|
|
198
|
+
for (const item of [...restored, ...conflicts, ...alreadyReverted]) {
|
|
199
|
+
console.log(` - [${item.action}] ${item.description}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -17,6 +17,9 @@ export interface RunSecretsAuditOptions {
|
|
|
17
17
|
/** Same test/sandbox-only seam as every other command — never a CLI
|
|
18
18
|
* flag. See docs/architecture.md's testing philosophy. */
|
|
19
19
|
homeDir?: string;
|
|
20
|
+
/** Onboard-only seam — see RunSyncOptions.managedAgents. Never a CLI
|
|
21
|
+
* flag. */
|
|
22
|
+
managedAgents?: readonly AgentId[];
|
|
20
23
|
}
|
|
21
24
|
export interface SecretsFinding {
|
|
22
25
|
/** "environment" for `missing-env-value` — that check isn't scoped to
|
|
@@ -33,3 +36,7 @@ export declare function collectSecretsAuditReport(opts?: RunSecretsAuditOptions)
|
|
|
33
36
|
export declare function runSecretsAudit(opts?: RunSecretsAuditOptions): Promise<{
|
|
34
37
|
exitCode: number;
|
|
35
38
|
}>;
|
|
39
|
+
/** Exported so `onboard` prints a secrets-audit report identically to
|
|
40
|
+
* running `secrets audit` standalone, instead of a second, easily-drifting
|
|
41
|
+
* copy of this formatting. */
|
|
42
|
+
export declare function printReport(report: SecretsAuditReport): void;
|
|
@@ -20,15 +20,18 @@ import { CodexAdapter } from "../adapters/codex.js";
|
|
|
20
20
|
import { KiroAdapter } from "../adapters/kiro.js";
|
|
21
21
|
import { declaredEnvNames, extractJsonEnvVarNames, extractTomlEnvVarNames } from "../lib/envVarNames.js";
|
|
22
22
|
import { resolveSecretEnv } from "../lib/secretEnv.js";
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/** Only agents in `managedAgents` — a present-but-unmanaged agent's config
|
|
24
|
+
* file is never even read (trellis-managed-agents), same restriction as
|
|
25
|
+
* `sync`/`mcp sync`. pi has no static, generated MCP config file to audit
|
|
26
|
+
* regardless of management — its bridge reads mcp/servers.yaml directly at
|
|
27
|
+
* its own runtime (P4). See design.md Non-Goals in trellis-secrets-audit-p3. */
|
|
28
|
+
function auditedAgents(homeDir, managedAgents) {
|
|
29
|
+
const all = [
|
|
25
30
|
{ id: "claude-code", probe: () => new ClaudeCodeAdapter(homeDir).probe(), configPath: (h) => join(h, ".claude.json"), extractNames: extractJsonEnvVarNames },
|
|
26
31
|
{ id: "codex", probe: () => new CodexAdapter(homeDir).probe(), configPath: (h) => join(h, ".codex", "config.toml"), extractNames: extractTomlEnvVarNames },
|
|
27
32
|
{ id: "kiro", probe: () => new KiroAdapter(homeDir).probe(), configPath: (h) => join(h, ".kiro", "settings", "mcp.json"), extractNames: extractJsonEnvVarNames },
|
|
28
|
-
// pi has no static, generated MCP config file to audit — its bridge
|
|
29
|
-
// reads mcp/servers.yaml directly at its own runtime (P4). See
|
|
30
|
-
// design.md Non-Goals in trellis-secrets-audit-p3.
|
|
31
33
|
];
|
|
34
|
+
return all.filter((a) => managedAgents.includes(a.id));
|
|
32
35
|
}
|
|
33
36
|
/**
|
|
34
37
|
* Not scoped to any agent, present or not — a name that can't resolve is
|
|
@@ -72,8 +75,9 @@ function auditFile(agent, file, content, policy, extractNames) {
|
|
|
72
75
|
export async function collectSecretsAuditReport(opts = {}) {
|
|
73
76
|
const homeDir = opts.homeDir ?? homedir();
|
|
74
77
|
const canonical = loadCanonicalSource(homeDir);
|
|
78
|
+
const managedAgents = opts.managedAgents ?? canonical.managedAgents;
|
|
75
79
|
const findings = [];
|
|
76
|
-
for (const agent of auditedAgents(homeDir)) {
|
|
80
|
+
for (const agent of auditedAgents(homeDir, managedAgents)) {
|
|
77
81
|
const probeResult = await agent.probe();
|
|
78
82
|
if (!probeResult.present)
|
|
79
83
|
continue;
|
|
@@ -103,7 +107,10 @@ export async function runSecretsAudit(opts = {}) {
|
|
|
103
107
|
}
|
|
104
108
|
return { exitCode: report.findings.length > 0 ? 1 : 0 };
|
|
105
109
|
}
|
|
106
|
-
|
|
110
|
+
/** Exported so `onboard` prints a secrets-audit report identically to
|
|
111
|
+
* running `secrets audit` standalone, instead of a second, easily-drifting
|
|
112
|
+
* copy of this formatting. */
|
|
113
|
+
export function printReport(report) {
|
|
107
114
|
if (report.findings.length === 0) {
|
|
108
115
|
console.log("✅ no findings — every present agent's real config and every declared env var passed all checks");
|
|
109
116
|
return;
|
package/dist/commands/sync.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { AdapterPlanItem } from "../core/adapter.js";
|
|
9
9
|
import type { AgentId } from "../core/types.js";
|
|
10
|
+
import { type BackupSession } from "../lib/backup.js";
|
|
10
11
|
export interface RunSyncOptions {
|
|
11
12
|
/** Omit to sync both. */
|
|
12
13
|
target?: "skills" | "instructions";
|
|
@@ -21,6 +22,18 @@ export interface RunSyncOptions {
|
|
|
21
22
|
homeDir?: string;
|
|
22
23
|
/** Compute and report the plan without calling adapter.apply(). */
|
|
23
24
|
dryRun?: boolean;
|
|
25
|
+
/** Onboard-only seam: preview/act against a managed-agent set that
|
|
26
|
+
* hasn't been written to `~/.trellis/managed.yaml` yet (its own
|
|
27
|
+
* `--dry-run` still needs a real plan against the set the user is
|
|
28
|
+
* about to select, not the one already on disk). Never a CLI flag —
|
|
29
|
+
* standalone `trellis sync` always reads canonical's own. */
|
|
30
|
+
managedAgents?: readonly AgentId[];
|
|
31
|
+
/** Onboard-only seam (trellis-backup-rollback): a session already open
|
|
32
|
+
* for the whole onboard run, so one `trellis rollback` undoes every
|
|
33
|
+
* stage together instead of one per stage. When given, this function
|
|
34
|
+
* does NOT finalize it — only whoever opened it does. Never a CLI
|
|
35
|
+
* flag — standalone `trellis sync` always opens and finalizes its own. */
|
|
36
|
+
backupSession?: BackupSession;
|
|
24
37
|
}
|
|
25
38
|
export interface AgentSyncReport {
|
|
26
39
|
agent: AgentId;
|
package/dist/commands/sync.js
CHANGED
|
@@ -11,14 +11,35 @@ import { ClaudeCodeAdapter } from "../adapters/claude-code.js";
|
|
|
11
11
|
import { CodexAdapter } from "../adapters/codex.js";
|
|
12
12
|
import { KiroAdapter } from "../adapters/kiro.js";
|
|
13
13
|
import { PiAdapter } from "../adapters/pi.js";
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
import { openBackupSession } from "../lib/backup.js";
|
|
15
|
+
const ADAPTER_FACTORY = {
|
|
16
|
+
"claude-code": (homeDir) => new ClaudeCodeAdapter(homeDir),
|
|
17
|
+
codex: (homeDir) => new CodexAdapter(homeDir),
|
|
18
|
+
kiro: (homeDir) => new KiroAdapter(homeDir),
|
|
19
|
+
pi: (homeDir) => new PiAdapter(homeDir),
|
|
20
|
+
};
|
|
21
|
+
/** Only agents in `canonical.managedAgents` — an agent present on this
|
|
22
|
+
* machine but not managed gets no adapter at all, not a zero-item plan
|
|
23
|
+
* (trellis-managed-agents). */
|
|
24
|
+
function buildAdapters(homeDir, managedAgents) {
|
|
25
|
+
return managedAgents.map((id) => ADAPTER_FACTORY[id](homeDir));
|
|
16
26
|
}
|
|
17
27
|
export async function collectSyncReport(opts = {}) {
|
|
18
28
|
const homeDir = opts.homeDir ?? homedir();
|
|
19
|
-
const
|
|
29
|
+
const loaded = loadCanonicalSource(homeDir);
|
|
30
|
+
// A single override point, not two: every downstream read of "who's
|
|
31
|
+
// managed" — the adapter list built here AND each adapter's own
|
|
32
|
+
// in-scope filtering via `canonical.managedAgents` — must agree, or a
|
|
33
|
+
// dry-run preview computed against a not-yet-written managed set would
|
|
34
|
+
// silently diverge from what onboard's own outer adapter list acted on.
|
|
35
|
+
const canonical = opts.managedAgents ? { ...loaded, managedAgents: opts.managedAgents } : loaded;
|
|
20
36
|
const reports = [];
|
|
21
|
-
|
|
37
|
+
// Own session only when the caller didn't share one (trellis-backup-
|
|
38
|
+
// rollback D2) — dry-run never opens or writes one, there's nothing to
|
|
39
|
+
// record.
|
|
40
|
+
const ownSession = !opts.dryRun && !opts.backupSession ? openBackupSession(homeDir, "sync") : undefined;
|
|
41
|
+
const backup = opts.backupSession ?? ownSession;
|
|
42
|
+
for (const adapter of buildAdapters(homeDir, canonical.managedAgents)) {
|
|
22
43
|
const probeResult = await adapter.probe();
|
|
23
44
|
if (!probeResult.present) {
|
|
24
45
|
reports.push({ agent: adapter.id, present: false, items: [] });
|
|
@@ -40,10 +61,11 @@ export async function collectSyncReport(opts = {}) {
|
|
|
40
61
|
items = items.filter((item) => item.kind === kind);
|
|
41
62
|
}
|
|
42
63
|
if (!opts.dryRun) {
|
|
43
|
-
await adapter.apply(items);
|
|
64
|
+
await adapter.apply(items, backup);
|
|
44
65
|
}
|
|
45
66
|
reports.push({ agent: adapter.id, present: true, items });
|
|
46
67
|
}
|
|
68
|
+
ownSession?.finalize();
|
|
47
69
|
return { reports };
|
|
48
70
|
}
|
|
49
71
|
export async function runSync(opts = {}) {
|
|
@@ -70,6 +92,10 @@ export async function runSync(opts = {}) {
|
|
|
70
92
|
export function printReport(report, dryRun) {
|
|
71
93
|
if (dryRun)
|
|
72
94
|
console.log("[dry run]");
|
|
95
|
+
if (report.reports.length === 0) {
|
|
96
|
+
console.log("No managed agents yet — run `trellis onboard` or list agent ids in ~/.trellis/managed.yaml.");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
73
99
|
for (const { agent, present, items } of report.reports) {
|
|
74
100
|
if (!present) {
|
|
75
101
|
console.log(`— ${agent} (not installed)`);
|
package/dist/core/adapter.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { AgentId, CanonicalSource, McpServerDef } from "./types.js";
|
|
12
12
|
import { resolveScope } from "./types.js";
|
|
13
|
+
import type { BackupSession } from "../lib/backup.js";
|
|
13
14
|
export interface AdapterProbeResult {
|
|
14
15
|
present: boolean;
|
|
15
16
|
version?: string;
|
|
@@ -91,7 +92,7 @@ export interface TrellisAdapter {
|
|
|
91
92
|
* entries, MCP servers) through its `scope` field before planning any
|
|
92
93
|
* change for it — an item scoped away from `this.id` must produce no
|
|
93
94
|
* plan item at all, not a plan item that's later skipped. Use
|
|
94
|
-
* `
|
|
95
|
+
* `isInScope(this.id, item.scope, canonical.managedAgents)`. See docs/architecture.md
|
|
95
96
|
* "Private / agent-specific capabilities" — the default (`scope`
|
|
96
97
|
* omitted) is "all agents," so this filter is a no-op for the common
|
|
97
98
|
* case and only actually excludes anything when a capability was
|
|
@@ -123,11 +124,17 @@ export interface TrellisAdapter {
|
|
|
123
124
|
* item in the same plan from being applied. The caller (e.g. `trellis
|
|
124
125
|
* sync`) is responsible for surfacing conflicts and failing the overall
|
|
125
126
|
* command (non-zero exit), not `apply()` per item.
|
|
127
|
+
*
|
|
128
|
+
* MUST perform every real write through `backup` (`writeFile`/
|
|
129
|
+
* `createSymlink`/`repairSymlink`/`removeSymlink`), never directly —
|
|
130
|
+
* `backup` is the only thing that may call `fs`/`fs/promises` to
|
|
131
|
+
* mutate a path this method touches (trellis-backup-rollback design.md
|
|
132
|
+
* D3/D5). Mandatory, not optional: the caller always has one open.
|
|
126
133
|
*/
|
|
127
|
-
apply(plan: AdapterPlanItem[]): Promise<void>;
|
|
134
|
+
apply(plan: AdapterPlanItem[], backup: BackupSession): Promise<void>;
|
|
128
135
|
/** Re-read the agent's own state and confirm it matches canonical. */
|
|
129
136
|
verify(canonical: CanonicalSource): Promise<AdapterVerifyResult>;
|
|
130
137
|
}
|
|
131
138
|
/** Convenience used by every adapter's `plan()` — see the scope-filtering
|
|
132
139
|
* obligation documented above. */
|
|
133
|
-
export declare function isInScope(id: AgentId, scope: Parameters<typeof resolveScope>[0]): boolean;
|
|
140
|
+
export declare function isInScope(id: AgentId, scope: Parameters<typeof resolveScope>[0], managedAgents: readonly AgentId[]): boolean;
|
package/dist/core/adapter.js
CHANGED
|
@@ -11,6 +11,6 @@
|
|
|
11
11
|
import { resolveScope } from "./types.js";
|
|
12
12
|
/** Convenience used by every adapter's `plan()` — see the scope-filtering
|
|
13
13
|
* obligation documented above. */
|
|
14
|
-
export function isInScope(id, scope) {
|
|
15
|
-
return resolveScope(scope).includes(id);
|
|
14
|
+
export function isInScope(id, scope, managedAgents) {
|
|
15
|
+
return resolveScope(scope, managedAgents).includes(id);
|
|
16
16
|
}
|
package/dist/core/canonical.js
CHANGED
|
@@ -78,6 +78,26 @@ function loadSecretsPolicyYaml(path, homeDir) {
|
|
|
78
78
|
/** `undefined` if the name has no entry in scope.yaml's map — "shared with
|
|
79
79
|
* all four agents," the default. A recognized but empty list is left as
|
|
80
80
|
* authored (an explicitly agent-less scope), not coerced to "all". */
|
|
81
|
+
/** Missing file and `agents: []` both resolve to `[]` — zero managed
|
|
82
|
+
* agents (D1), never "everyone." An unrecognized id is dropped with a
|
|
83
|
+
* diagnostic, same posture as an unrecognized scope.yaml agent id. */
|
|
84
|
+
function loadManagedYaml(path, diagnostics) {
|
|
85
|
+
if (!existsSync(path)) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
const parsed = (parseYaml(readFileSync(path, "utf-8")) ?? {});
|
|
89
|
+
const raw = parsed.agents ?? [];
|
|
90
|
+
const valid = [];
|
|
91
|
+
for (const id of raw) {
|
|
92
|
+
if (ALL_AGENTS.includes(id)) {
|
|
93
|
+
valid.push(id);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
diagnostics.push(`managed.yaml: "${id}" is not a recognized agent id — ignored`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return valid;
|
|
100
|
+
}
|
|
81
101
|
function scopeFor(map, name) {
|
|
82
102
|
return map?.[name];
|
|
83
103
|
}
|
|
@@ -100,6 +120,7 @@ export function loadCanonicalSource(homeDir = homedir()) {
|
|
|
100
120
|
throw new Error(`No canonical source at ${root}. Create it before running trellis sync — see docs/architecture.md's canonical schema.`);
|
|
101
121
|
}
|
|
102
122
|
const diagnostics = [];
|
|
123
|
+
const managedAgents = loadManagedYaml(join(root, "managed.yaml"), diagnostics);
|
|
103
124
|
const scopeYaml = loadScopeYaml(join(root, "scope.yaml"));
|
|
104
125
|
const skillDirs = listSkillDirs(join(root, "skills"));
|
|
105
126
|
const knownSkillNames = new Set(skillDirs.map((s) => s.name));
|
|
@@ -135,6 +156,7 @@ export function loadCanonicalSource(homeDir = homedir()) {
|
|
|
135
156
|
}
|
|
136
157
|
return {
|
|
137
158
|
instructionsFile: join(root, "agents.md"),
|
|
159
|
+
managedAgents,
|
|
138
160
|
skills,
|
|
139
161
|
agents,
|
|
140
162
|
memories,
|
package/dist/core/types.d.ts
CHANGED
|
@@ -15,7 +15,15 @@ export declare const ALL_AGENTS: readonly AgentId[];
|
|
|
15
15
|
* "Private / agent-specific capabilities".
|
|
16
16
|
*/
|
|
17
17
|
export type Scope = AgentId[] | undefined;
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* `managedAgents` is the hard outer boundary (trellis-managed-agents
|
|
20
|
+
* design.md D6): no-scope items resolve to it instead of `ALL_AGENTS`, and
|
|
21
|
+
* an item WITH an explicit scope is intersected with it, never returned
|
|
22
|
+
* verbatim — a skill scoped to `[kiro]` must still not reach Kiro if Kiro
|
|
23
|
+
* isn't in the managed set. Presence on the machine is irrelevant here;
|
|
24
|
+
* only membership in `managedAgents` is.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveScope(scope: Scope, managedAgents: readonly AgentId[]): readonly AgentId[];
|
|
19
27
|
export interface McpServerDef {
|
|
20
28
|
transport: Transport;
|
|
21
29
|
/** stdio only */
|
|
@@ -190,6 +198,9 @@ export interface CanonicalSource {
|
|
|
190
198
|
* that decision.
|
|
191
199
|
*/
|
|
192
200
|
instructionsFile: string;
|
|
201
|
+
/** From `~/.trellis/managed.yaml`. Missing file and `agents: []` both
|
|
202
|
+
* resolve to `[]` — zero managed agents, never "everyone" (D1). */
|
|
203
|
+
managedAgents: readonly AgentId[];
|
|
193
204
|
skills: SkillRef[];
|
|
194
205
|
agents: AgentProfile[];
|
|
195
206
|
memories: MemoryEntry[];
|
package/dist/core/types.js
CHANGED
|
@@ -10,6 +10,15 @@ export const ALL_AGENTS = [
|
|
|
10
10
|
"kiro",
|
|
11
11
|
"pi",
|
|
12
12
|
];
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* `managedAgents` is the hard outer boundary (trellis-managed-agents
|
|
15
|
+
* design.md D6): no-scope items resolve to it instead of `ALL_AGENTS`, and
|
|
16
|
+
* an item WITH an explicit scope is intersected with it, never returned
|
|
17
|
+
* verbatim — a skill scoped to `[kiro]` must still not reach Kiro if Kiro
|
|
18
|
+
* isn't in the managed set. Presence on the machine is irrelevant here;
|
|
19
|
+
* only membership in `managedAgents` is.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveScope(scope, managedAgents) {
|
|
22
|
+
const candidates = scope ?? managedAgents;
|
|
23
|
+
return candidates.filter((id) => managedAgents.includes(id));
|
|
15
24
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured, timestamped backup of every real write `sync`/`mcp sync`
|
|
3
|
+
* perform, and the only path any of them writes disk through — see
|
|
4
|
+
* openspec/changes/trellis-backup-rollback/design.md D3/D5 for why the
|
|
5
|
+
* write itself lives here rather than at each call site: a call site
|
|
6
|
+
* that only had to remember to *also* call a record function is exactly
|
|
7
|
+
* the class of bug trellis-managed-agents found in symlinkPlan.ts. A run
|
|
8
|
+
* directory is created lazily on the first recorded operation; a session
|
|
9
|
+
* that never records anything creates nothing on disk.
|
|
10
|
+
*/
|
|
11
|
+
export type BackupOperation = {
|
|
12
|
+
kind: "file-create";
|
|
13
|
+
path: string;
|
|
14
|
+
afterHash: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: "file-overwrite";
|
|
17
|
+
path: string;
|
|
18
|
+
beforeFile: string;
|
|
19
|
+
beforeHash: string;
|
|
20
|
+
afterHash: string;
|
|
21
|
+
} | {
|
|
22
|
+
kind: "symlink-create";
|
|
23
|
+
path: string;
|
|
24
|
+
afterLinkTarget: string;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "symlink-repair";
|
|
27
|
+
path: string;
|
|
28
|
+
beforeLinkTarget: string;
|
|
29
|
+
afterLinkTarget: string;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "symlink-remove";
|
|
32
|
+
path: string;
|
|
33
|
+
beforeLinkTarget: string;
|
|
34
|
+
};
|
|
35
|
+
export interface BackupManifest {
|
|
36
|
+
runId: string;
|
|
37
|
+
command: string;
|
|
38
|
+
startedAt: string;
|
|
39
|
+
operations: BackupOperation[];
|
|
40
|
+
}
|
|
41
|
+
export interface BackupSession {
|
|
42
|
+
writeFile(path: string, content: string): void;
|
|
43
|
+
createSymlink(path: string, linkTarget: string): Promise<void>;
|
|
44
|
+
repairSymlink(path: string, oldLinkTarget: string, newLinkTarget: string): Promise<void>;
|
|
45
|
+
removeSymlink(path: string, oldLinkTarget: string): Promise<void>;
|
|
46
|
+
/** Writes manifest.json. No-op (creates nothing) if zero operations
|
|
47
|
+
* were ever recorded. */
|
|
48
|
+
finalize(): void;
|
|
49
|
+
}
|
|
50
|
+
export declare function backupsRoot(homeDir: string): string;
|
|
51
|
+
export declare function openBackupSession(homeDir: string, command: string): BackupSession;
|
|
52
|
+
/** Used by `applySymlinkPlan` and every adapter's own read-before-repair
|
|
53
|
+
* logic — it needs the symlink's current stored target before the
|
|
54
|
+
* session's own repair/remove overwrites it. Not part of `BackupSession`
|
|
55
|
+
* itself: it's a read, not a recorded write. */
|
|
56
|
+
export declare function currentLinkTarget(path: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured, timestamped backup of every real write `sync`/`mcp sync`
|
|
3
|
+
* perform, and the only path any of them writes disk through — see
|
|
4
|
+
* openspec/changes/trellis-backup-rollback/design.md D3/D5 for why the
|
|
5
|
+
* write itself lives here rather than at each call site: a call site
|
|
6
|
+
* that only had to remember to *also* call a record function is exactly
|
|
7
|
+
* the class of bug trellis-managed-agents found in symlinkPlan.ts. A run
|
|
8
|
+
* directory is created lazily on the first recorded operation; a session
|
|
9
|
+
* that never records anything creates nothing on disk.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { readlink, rm, symlink } from "node:fs/promises";
|
|
14
|
+
import { basename, join } from "node:path";
|
|
15
|
+
function sha256(content) {
|
|
16
|
+
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
17
|
+
}
|
|
18
|
+
/** `:` and `.` are legal in POSIX filenames but awkward across shells and
|
|
19
|
+
* some tooling; replaced so a run id is safe to pass around bare. */
|
|
20
|
+
function runIdFor(command) {
|
|
21
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
22
|
+
return `${ts}-${command}`;
|
|
23
|
+
}
|
|
24
|
+
export function backupsRoot(homeDir) {
|
|
25
|
+
return join(homeDir, ".trellis", "backups");
|
|
26
|
+
}
|
|
27
|
+
export function openBackupSession(homeDir, command) {
|
|
28
|
+
const runId = runIdFor(command);
|
|
29
|
+
const runDir = join(backupsRoot(homeDir), runId);
|
|
30
|
+
const startedAt = new Date().toISOString();
|
|
31
|
+
const operations = [];
|
|
32
|
+
let dirCreated = false;
|
|
33
|
+
let fileIndex = 0;
|
|
34
|
+
function ensureDir() {
|
|
35
|
+
if (dirCreated)
|
|
36
|
+
return;
|
|
37
|
+
mkdirSync(join(runDir, "files"), { recursive: true });
|
|
38
|
+
dirCreated = true;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
writeFile(path, content) {
|
|
42
|
+
ensureDir();
|
|
43
|
+
const afterHash = sha256(content);
|
|
44
|
+
if (existsSync(path)) {
|
|
45
|
+
const before = readFileSync(path, "utf-8");
|
|
46
|
+
const relSnapshot = join("files", `${fileIndex}-${basename(path)}`);
|
|
47
|
+
fileIndex += 1;
|
|
48
|
+
writeFileSync(join(runDir, relSnapshot), before);
|
|
49
|
+
operations.push({
|
|
50
|
+
kind: "file-overwrite",
|
|
51
|
+
path,
|
|
52
|
+
beforeFile: relSnapshot,
|
|
53
|
+
beforeHash: sha256(before),
|
|
54
|
+
afterHash,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
operations.push({ kind: "file-create", path, afterHash });
|
|
59
|
+
}
|
|
60
|
+
writeFileSync(path, content);
|
|
61
|
+
},
|
|
62
|
+
async createSymlink(path, linkTarget) {
|
|
63
|
+
ensureDir();
|
|
64
|
+
await rm(path, { force: true });
|
|
65
|
+
await symlink(linkTarget, path);
|
|
66
|
+
operations.push({ kind: "symlink-create", path, afterLinkTarget: linkTarget });
|
|
67
|
+
},
|
|
68
|
+
async repairSymlink(path, oldLinkTarget, newLinkTarget) {
|
|
69
|
+
ensureDir();
|
|
70
|
+
await rm(path, { force: true });
|
|
71
|
+
await symlink(newLinkTarget, path);
|
|
72
|
+
operations.push({ kind: "symlink-repair", path, beforeLinkTarget: oldLinkTarget, afterLinkTarget: newLinkTarget });
|
|
73
|
+
},
|
|
74
|
+
async removeSymlink(path, oldLinkTarget) {
|
|
75
|
+
ensureDir();
|
|
76
|
+
await rm(path, { force: true });
|
|
77
|
+
operations.push({ kind: "symlink-remove", path, beforeLinkTarget: oldLinkTarget });
|
|
78
|
+
},
|
|
79
|
+
finalize() {
|
|
80
|
+
if (!dirCreated)
|
|
81
|
+
return;
|
|
82
|
+
const manifest = { runId, command, startedAt, operations };
|
|
83
|
+
writeFileSync(join(runDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Used by `applySymlinkPlan` and every adapter's own read-before-repair
|
|
88
|
+
* logic — it needs the symlink's current stored target before the
|
|
89
|
+
* session's own repair/remove overwrites it. Not part of `BackupSession`
|
|
90
|
+
* itself: it's a read, not a recorded write. */
|
|
91
|
+
export async function currentLinkTarget(path) {
|
|
92
|
+
try {
|
|
93
|
+
return await readlink(path);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|