agent-trellis 0.1.0 → 0.3.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 +69 -17
- package/dist/adapters/claude-code.d.ts +5 -3
- package/dist/adapters/claude-code.js +27 -14
- package/dist/adapters/codex.d.ts +8 -4
- package/dist/adapters/codex.js +47 -16
- package/dist/adapters/jsonMcp.d.ts +16 -5
- package/dist/adapters/jsonMcp.js +38 -29
- package/dist/adapters/kiro.d.ts +5 -3
- package/dist/adapters/kiro.js +29 -16
- package/dist/adapters/mcpPlan.d.ts +11 -6
- package/dist/adapters/mcpPlan.js +40 -7
- 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 +161 -18
- package/dist/commands/init.js +11 -0
- package/dist/commands/mcp.d.ts +114 -7
- package/dist/commands/mcp.js +258 -17
- package/dist/commands/memory.d.ts +39 -0
- package/dist/commands/memory.js +78 -0
- package/dist/commands/migrate.d.ts +30 -4
- package/dist/commands/migrate.js +83 -16
- package/dist/commands/onboard.d.ts +52 -7
- package/dist/commands/onboard.js +318 -35
- 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/skill.d.ts +51 -0
- package/dist/commands/skill.js +104 -0
- package/dist/commands/sync.d.ts +13 -0
- package/dist/commands/sync.js +31 -5
- package/dist/core/adapter.d.ts +28 -11
- package/dist/core/adapter.js +2 -2
- package/dist/core/canonical.d.ts +26 -1
- package/dist/core/canonical.js +103 -3
- package/dist/core/types.d.ts +29 -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/deepEqual.d.ts +8 -0
- package/dist/lib/deepEqual.js +26 -0
- package/dist/lib/dirEquals.d.ts +9 -0
- package/dist/lib/dirEquals.js +15 -1
- package/dist/lib/installAgent.d.ts +26 -0
- package/dist/lib/installAgent.js +46 -0
- package/dist/lib/mcpMigrateRead.d.ts +69 -0
- package/dist/lib/mcpMigrateRead.js +188 -0
- package/dist/lib/mcpOwnership.d.ts +25 -0
- package/dist/lib/mcpOwnership.js +50 -0
- package/dist/lib/memoryGraph.d.ts +60 -0
- package/dist/lib/memoryGraph.js +101 -0
- package/dist/lib/realHomeSnapshot.d.ts +26 -0
- package/dist/lib/realHomeSnapshot.js +77 -0
- package/dist/lib/terminalPicker.d.ts +45 -0
- package/dist/lib/terminalPicker.js +193 -0
- package/dist/lib/tomlSection.d.ts +20 -6
- package/dist/lib/tomlSection.js +78 -12
- package/dist/pi-bridge/bundle.js +100 -51
- package/dist/pi-bridge/index.js +14 -2
- package/dist/probes/codex.js +10 -2
- package/docs/architecture.md +7 -4
- package/docs/getting-started.md +267 -33
- package/docs/roadmap.md +444 -0
- package/package.json +1 -1
- package/schema/servers.example.yaml +39 -2
package/dist/commands/onboard.js
CHANGED
|
@@ -1,20 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `trellis onboard` — chains `init` → agent detection →
|
|
3
|
-
* resolution →
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* `trellis onboard` — chains `init` → agent detection → migration-source
|
|
3
|
+
* resolution → migrate-category selection (trellis-migrate-category-
|
|
4
|
+
* selection) → managed-agent-set selection (install-then-manage for a
|
|
5
|
+
* selected, not-yet-present agent) → `migrate` → `sync` → `mcp sync` →
|
|
6
|
+
* `secrets audit` into one guided flow (trellis-cli-onboard,
|
|
7
|
+
* trellis-managed-agents) — a user should never have to type a second
|
|
8
|
+
* command by hand to finish onboarding. Source, categories, and managed
|
|
9
|
+
* set are three independent choices (design.md D2): importing from a
|
|
10
|
+
* source never writes back to it, and it is not implicitly added to the
|
|
11
|
+
* managed set. Orchestrates existing commands' own plan/apply logic; no
|
|
12
|
+
* new skill-copy, symlink, conflict-detection, or secrets-scanning
|
|
6
13
|
* judgment is made here.
|
|
7
14
|
*/
|
|
8
15
|
import { createInterface } from "node:readline/promises";
|
|
9
16
|
import { homedir } from "node:os";
|
|
17
|
+
import { writeFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { canUseInteractivePicker, runMultiSelectPicker, runSingleSelectPicker } from "../lib/terminalPicker.js";
|
|
10
20
|
import * as claudeCodeProbe from "../probes/claude-code.js";
|
|
11
21
|
import * as codexProbe from "../probes/codex.js";
|
|
12
22
|
import * as kiroProbe from "../probes/kiro.js";
|
|
13
23
|
import * as piProbe from "../probes/pi.js";
|
|
14
24
|
import { ALL_AGENTS } from "../core/types.js";
|
|
25
|
+
import { loadCanonicalSource } from "../core/canonical.js";
|
|
15
26
|
import { INSTALL_HINTS, collectInitReport } from "./init.js";
|
|
16
27
|
import { applyMigratePlan, collectMigratePlan, printPlan as printMigratePlan } from "./migrate.js";
|
|
17
28
|
import { collectSyncReport, printReport as printSyncReport } from "./sync.js";
|
|
29
|
+
import { collectMcpSyncReport, printReport as printMcpSyncReport } from "./mcp.js";
|
|
30
|
+
import { collectSecretsAuditReport, printReport as printSecretsAuditReport } from "./secretsAudit.js";
|
|
31
|
+
import { openBackupSession } from "../lib/backup.js";
|
|
32
|
+
import { confirmAndInstall } from "../lib/installAgent.js";
|
|
18
33
|
const PROBES = {
|
|
19
34
|
"claude-code": (homeDir) => claudeCodeProbe.probe(homeDir),
|
|
20
35
|
codex: (homeDir) => codexProbe.probe(homeDir),
|
|
@@ -32,26 +47,195 @@ export async function collectOnboardSummary(homeDir = homedir()) {
|
|
|
32
47
|
return { agent, present: true, skillCount: skillNames.length, skillNames, hasRealInstructions };
|
|
33
48
|
}));
|
|
34
49
|
}
|
|
35
|
-
|
|
50
|
+
function hasContent(s) {
|
|
51
|
+
return s.skillCount > 0 || s.hasRealInstructions;
|
|
52
|
+
}
|
|
53
|
+
function agentSummaryLabel(s) {
|
|
54
|
+
const skills = s.skillCount > 0 ? ` (${s.skillNames.join(", ")})` : "";
|
|
55
|
+
return `${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}`;
|
|
56
|
+
}
|
|
57
|
+
/** Numbered-typing fallback (trellis-onboard-interactive-picker design.md
|
|
58
|
+
* D2) — used only when the terminal can't support the raw-mode picker
|
|
59
|
+
* (`canUseInteractivePicker()` false). Unchanged from before that change. */
|
|
60
|
+
async function promptForAgentNumbered(present) {
|
|
36
61
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
37
62
|
try {
|
|
38
63
|
console.log("Multiple agents detected:");
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
64
|
+
present.forEach((s, i) => {
|
|
65
|
+
console.log(` ${i + 1}) ${agentSummaryLabel(s)}`);
|
|
66
|
+
});
|
|
43
67
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
44
|
-
const answer = (await rl.question(
|
|
68
|
+
const answer = (await rl.question(`Choose a migration source [1-${present.length}]: `)).trim();
|
|
69
|
+
// Accepts either the number shown or the literal agent id — the
|
|
70
|
+
// latter kept so `--agent`-equivalent scripted callers piping a
|
|
71
|
+
// canned answer in don't have to know the numbering.
|
|
72
|
+
const byIndex = present[Number(answer) - 1];
|
|
73
|
+
if (byIndex)
|
|
74
|
+
return byIndex.agent;
|
|
45
75
|
if (present.some((s) => s.agent === answer))
|
|
46
76
|
return answer;
|
|
47
|
-
console.log(`Not
|
|
77
|
+
console.log(`Not a valid choice: enter a number from 1-${present.length}, or one of ${present.map((s) => s.agent).join(", ")}`);
|
|
48
78
|
}
|
|
49
|
-
throw new Error("no valid
|
|
79
|
+
throw new Error("no valid migration source chosen after 2 attempts");
|
|
50
80
|
}
|
|
51
81
|
finally {
|
|
52
82
|
rl.close();
|
|
53
83
|
}
|
|
54
84
|
}
|
|
85
|
+
/** Arrow-key single-select on a real, raw-mode-capable terminal; falls
|
|
86
|
+
* back to `promptForAgentNumbered` otherwise. Resolves to the exact same
|
|
87
|
+
* string contract either way — a real agent id — so `resolveMigrationSource`
|
|
88
|
+
* and every test injecting `RunOnboardOptions.promptForAgent` need no
|
|
89
|
+
* changes (design.md D5). Cancel (Ctrl+C) prints a message and exits
|
|
90
|
+
* directly, rather than threading a new "cancelled" state through the
|
|
91
|
+
* rest of onboard's return-based refusal plumbing. */
|
|
92
|
+
async function promptForAgentReal(present) {
|
|
93
|
+
if (!canUseInteractivePicker()) {
|
|
94
|
+
return promptForAgentNumbered(present);
|
|
95
|
+
}
|
|
96
|
+
console.log("Multiple agents detected — use Up/Down (or j/k) and Enter to choose a migration source:");
|
|
97
|
+
const index = await runSingleSelectPicker(present.map(agentSummaryLabel));
|
|
98
|
+
if (index === null) {
|
|
99
|
+
console.log("cancelled, no changes made");
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
return present[index].agent;
|
|
103
|
+
}
|
|
104
|
+
/** Numbered-typing fallback — unchanged from before this change (see
|
|
105
|
+
* `promptForAgentNumbered`'s doc comment). */
|
|
106
|
+
async function promptForManagedAgentsNumbered(candidates, alreadyManaged) {
|
|
107
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
108
|
+
try {
|
|
109
|
+
console.log("Which agents should Trellis manage? (comma-separated numbers; enter for none new)");
|
|
110
|
+
candidates.forEach((s, i) => {
|
|
111
|
+
const status = s.present ? `present, ${s.skillCount} skill(s)` : "not installed";
|
|
112
|
+
const tag = alreadyManaged.includes(s.agent) ? " [already managed]" : "";
|
|
113
|
+
console.log(` ${i + 1}) ${s.agent} — ${status}${tag}`);
|
|
114
|
+
});
|
|
115
|
+
const answer = (await rl.question("Select: ")).trim();
|
|
116
|
+
return answer;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
rl.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Checkbox multi-select on a real, raw-mode-capable terminal; falls
|
|
123
|
+
* back to `promptForManagedAgentsNumbered` otherwise. Resolves to the
|
|
124
|
+
* same comma-separated-agent-id string contract `parseManagedSelection`
|
|
125
|
+
* already parses — an empty selection resolves to `""` (comma-separated
|
|
126
|
+
* join of zero items), which `parseManagedSelection` already treats as
|
|
127
|
+
* "none" (design.md D5). Cancel behaves like `promptForAgentReal`'s. */
|
|
128
|
+
async function promptForManagedAgentsReal(candidates, alreadyManaged) {
|
|
129
|
+
if (!canUseInteractivePicker()) {
|
|
130
|
+
return promptForManagedAgentsNumbered(candidates, alreadyManaged);
|
|
131
|
+
}
|
|
132
|
+
console.log("Which agents should Trellis manage? Up/Down (or j/k) to move, Space to toggle, Enter to confirm:");
|
|
133
|
+
const labels = candidates.map((s) => {
|
|
134
|
+
const status = s.present ? `present, ${s.skillCount} skill(s)` : "not installed";
|
|
135
|
+
return `${s.agent} — ${status}`;
|
|
136
|
+
});
|
|
137
|
+
const initiallyChecked = candidates.map((s) => alreadyManaged.includes(s.agent));
|
|
138
|
+
const indices = await runMultiSelectPicker(labels, initiallyChecked);
|
|
139
|
+
if (indices === null) {
|
|
140
|
+
console.log("cancelled, no changes made");
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
return indices.map((i) => candidates[i].agent).join(",");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Resolves which categories (skills, instructions) to migrate from a
|
|
147
|
+
* resolved source (trellis-migrate-category-selection). Unlike the two
|
|
148
|
+
* pickers above, there is no numbered-text fallback to preserve parity
|
|
149
|
+
* with — this concept never existed before this change, so "can't
|
|
150
|
+
* prompt" simply means "default to whichever kind(s) actually have real
|
|
151
|
+
* content, silently" (design.md D4). The picker itself is only offered
|
|
152
|
+
* when the choice is meaningful — both kinds present, a capable
|
|
153
|
+
* terminal, and not a `--json` run (design.md D5). An empty result is a
|
|
154
|
+
* valid answer: "skip migrate for this run" (design.md D6), left for
|
|
155
|
+
* the caller to act on.
|
|
156
|
+
*/
|
|
157
|
+
async function resolveMigrateCategories(source, opts) {
|
|
158
|
+
const wantsSkills = source.skillCount > 0;
|
|
159
|
+
const wantsInstructions = source.hasRealInstructions;
|
|
160
|
+
// The choice is only meaningful when both kinds are real — same gate
|
|
161
|
+
// for the injected test seam as for the real picker, mirroring how
|
|
162
|
+
// `promptForAgent`/`promptForManagedAgents` are only ever consulted
|
|
163
|
+
// when their own real prompt would actually apply.
|
|
164
|
+
if (wantsSkills && wantsInstructions && !opts.json) {
|
|
165
|
+
if (opts.promptForMigrateCategories) {
|
|
166
|
+
return opts.promptForMigrateCategories(source);
|
|
167
|
+
}
|
|
168
|
+
if (canUseInteractivePicker()) {
|
|
169
|
+
console.log(`Which categories should be migrated from ${source.agent}? Space to toggle, Enter to confirm:`);
|
|
170
|
+
const indices = await runMultiSelectPicker(["skills", "instructions"], [true, true]);
|
|
171
|
+
if (indices === null) {
|
|
172
|
+
console.log("cancelled, no changes made");
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
const kinds = [];
|
|
176
|
+
if (indices.includes(0))
|
|
177
|
+
kinds.push("skill");
|
|
178
|
+
if (indices.includes(1))
|
|
179
|
+
kinds.push("instructions");
|
|
180
|
+
return kinds;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const kinds = [];
|
|
184
|
+
if (wantsSkills)
|
|
185
|
+
kinds.push("skill");
|
|
186
|
+
if (wantsInstructions)
|
|
187
|
+
kinds.push("instructions");
|
|
188
|
+
return kinds;
|
|
189
|
+
}
|
|
190
|
+
/** Shared by `--manage` and the interactive prompt's answer — same
|
|
191
|
+
* grammar either way (design.md D4): a real agent id list, comma- or
|
|
192
|
+
* whitespace-separated numbers referring to `candidates`' own order, or
|
|
193
|
+
* the literal `none`. Never guesses on an unparseable token. */
|
|
194
|
+
function parseManagedSelection(raw, candidates) {
|
|
195
|
+
const trimmed = raw.trim();
|
|
196
|
+
if (trimmed === "" || trimmed.toLowerCase() === "none") {
|
|
197
|
+
return { agents: [] };
|
|
198
|
+
}
|
|
199
|
+
const tokens = trimmed.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
|
|
200
|
+
const agents = [];
|
|
201
|
+
for (const token of tokens) {
|
|
202
|
+
const byIndex = candidates[Number(token) - 1];
|
|
203
|
+
if (byIndex) {
|
|
204
|
+
agents.push(byIndex.agent);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (ALL_AGENTS.includes(token)) {
|
|
208
|
+
agents.push(token);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
return { error: `"${token}" is not a valid choice — use a number from 1-${candidates.length} or an agent id` };
|
|
212
|
+
}
|
|
213
|
+
return { agents: [...new Set(agents)] };
|
|
214
|
+
}
|
|
215
|
+
async function resolveManagedAgents(opts, summary, alreadyManaged) {
|
|
216
|
+
if (opts.manage !== undefined) {
|
|
217
|
+
const parsed = parseManagedSelection(opts.manage, summary);
|
|
218
|
+
if ("error" in parsed)
|
|
219
|
+
return { refusal: parsed.error };
|
|
220
|
+
return { newlySelected: parsed.agents };
|
|
221
|
+
}
|
|
222
|
+
const canPrompt = !opts.json && (opts.isTTY ?? process.stdin.isTTY === true);
|
|
223
|
+
if (!canPrompt) {
|
|
224
|
+
return { refusal: "no managed-agent selection given and no terminal to prompt in — pass --manage <ids> or --manage none" };
|
|
225
|
+
}
|
|
226
|
+
const prompt = opts.promptForManagedAgents ?? promptForManagedAgentsReal;
|
|
227
|
+
const answer = await prompt(summary, alreadyManaged);
|
|
228
|
+
const parsed = parseManagedSelection(answer, summary);
|
|
229
|
+
if ("error" in parsed)
|
|
230
|
+
return { refusal: parsed.error };
|
|
231
|
+
return { newlySelected: parsed.agents };
|
|
232
|
+
}
|
|
233
|
+
function readManagedYaml(homeDir) {
|
|
234
|
+
return loadCanonicalSource(homeDir).managedAgents;
|
|
235
|
+
}
|
|
236
|
+
function writeManagedYaml(homeDir, agents) {
|
|
237
|
+
writeFileSync(join(homeDir, ".trellis", "managed.yaml"), `agents: [${agents.join(", ")}]\n`);
|
|
238
|
+
}
|
|
55
239
|
export async function collectOnboardPlan(opts = {}) {
|
|
56
240
|
const homeDir = opts.homeDir ?? homedir();
|
|
57
241
|
await collectInitReport(homeDir);
|
|
@@ -60,8 +244,9 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
60
244
|
if (present.length === 0) {
|
|
61
245
|
return { summary, installHints: { ...INSTALL_HINTS } };
|
|
62
246
|
}
|
|
63
|
-
|
|
64
|
-
let
|
|
247
|
+
const sourceCandidates = present.filter(hasContent);
|
|
248
|
+
let source;
|
|
249
|
+
let sourceReason;
|
|
65
250
|
if (opts.agent) {
|
|
66
251
|
const match = present.find((s) => s.agent === opts.agent);
|
|
67
252
|
if (!match) {
|
|
@@ -70,36 +255,110 @@ export async function collectOnboardPlan(opts = {}) {
|
|
|
70
255
|
refusal: `"${opts.agent}" is not one of the present agents (${present.map((s) => s.agent).join(", ")})`,
|
|
71
256
|
};
|
|
72
257
|
}
|
|
73
|
-
|
|
74
|
-
|
|
258
|
+
source = match.agent;
|
|
259
|
+
sourceReason = "flag";
|
|
75
260
|
}
|
|
76
|
-
else if (
|
|
77
|
-
|
|
78
|
-
|
|
261
|
+
else if (sourceCandidates.length === 1) {
|
|
262
|
+
source = sourceCandidates[0].agent;
|
|
263
|
+
sourceReason = "auto-selected";
|
|
79
264
|
}
|
|
80
|
-
else {
|
|
265
|
+
else if (sourceCandidates.length > 1) {
|
|
81
266
|
const canPrompt = !opts.json && (opts.isTTY ?? process.stdin.isTTY === true);
|
|
82
267
|
if (!canPrompt) {
|
|
83
268
|
return {
|
|
84
269
|
summary,
|
|
85
|
-
refusal: `multiple agents detected (${
|
|
270
|
+
refusal: `multiple agents detected (${sourceCandidates.map((s) => s.agent).join(", ")}) and no terminal to prompt in — pass --agent <id>`,
|
|
86
271
|
};
|
|
87
272
|
}
|
|
88
273
|
const prompt = opts.promptForAgent ?? promptForAgentReal;
|
|
89
274
|
try {
|
|
90
|
-
|
|
275
|
+
source = (await prompt(sourceCandidates));
|
|
91
276
|
}
|
|
92
277
|
catch (err) {
|
|
93
278
|
return { summary, refusal: err instanceof Error ? err.message : String(err) };
|
|
94
279
|
}
|
|
95
|
-
|
|
280
|
+
sourceReason = "prompt";
|
|
281
|
+
}
|
|
282
|
+
// sourceCandidates.length === 0: nothing with real content to migrate
|
|
283
|
+
// from — source stays undefined, managed-set selection still proceeds.
|
|
284
|
+
const alreadyManaged = readManagedYaml(homeDir);
|
|
285
|
+
const managedResult = await resolveManagedAgents(opts, summary, alreadyManaged);
|
|
286
|
+
if ("refusal" in managedResult) {
|
|
287
|
+
return { summary, source, sourceReason, refusal: managedResult.refusal };
|
|
288
|
+
}
|
|
289
|
+
const installResults = [];
|
|
290
|
+
const resolvedNew = [];
|
|
291
|
+
for (const agent of managedResult.newlySelected) {
|
|
292
|
+
const alreadyPresent = summary.find((s) => s.agent === agent)?.present ?? false;
|
|
293
|
+
if (alreadyPresent) {
|
|
294
|
+
resolvedNew.push(agent);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
// A selected, not-yet-present agent: install-then-manage (design.md
|
|
298
|
+
// D5). `--json`/non-interactive callers still get a real confirm
|
|
299
|
+
// step here (never silently installed) — with no injected `confirm`
|
|
300
|
+
// and no TTY, the real readline prompt itself will simply never
|
|
301
|
+
// resolve to "yes" in a non-interactive run, so nothing installs;
|
|
302
|
+
// callers that want this path automated must inject `opts.install`.
|
|
303
|
+
if (opts.json && !opts.install?.confirm) {
|
|
304
|
+
return {
|
|
305
|
+
summary,
|
|
306
|
+
source,
|
|
307
|
+
sourceReason,
|
|
308
|
+
refusal: `"${agent}" is not installed — installing it requires a confirmation, which --json never prompts for. Inject a confirm handler or install ${agent} first.`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const result = await confirmAndInstall(agent, opts.install);
|
|
312
|
+
installResults.push({ agent, installed: result.installed, installable: result.installable });
|
|
313
|
+
if (result.installed) {
|
|
314
|
+
resolvedNew.push(agent);
|
|
315
|
+
}
|
|
316
|
+
// Declined or (Kiro) not installable: excluded from this run's
|
|
317
|
+
// managed set, not an abort of the rest of the flow.
|
|
96
318
|
}
|
|
97
|
-
const
|
|
319
|
+
const managedAgents = [...new Set([...alreadyManaged, ...resolvedNew])];
|
|
98
320
|
if (!opts.dryRun) {
|
|
99
|
-
|
|
321
|
+
writeManagedYaml(homeDir, managedAgents);
|
|
100
322
|
}
|
|
101
|
-
|
|
102
|
-
|
|
323
|
+
let migratePlan;
|
|
324
|
+
let migrateSkipped;
|
|
325
|
+
if (source) {
|
|
326
|
+
const sourceSummary = summary.find((s) => s.agent === source);
|
|
327
|
+
const categories = await resolveMigrateCategories(sourceSummary, opts);
|
|
328
|
+
if (categories.length > 0) {
|
|
329
|
+
migratePlan = await collectMigratePlan(source, homeDir, categories);
|
|
330
|
+
if (!opts.dryRun) {
|
|
331
|
+
applyMigratePlan(migratePlan, homeDir);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
migrateSkipped = "migrate skipped — no categories selected";
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// One session for the whole chained run (trellis-backup-rollback) —
|
|
339
|
+
// `--dry-run` opens none, there's nothing either stage will write.
|
|
340
|
+
// Neither collectSyncReport nor collectMcpSyncReport finalizes a
|
|
341
|
+
// session they were handed; only this caller does, once, after both.
|
|
342
|
+
const backupSession = opts.dryRun ? undefined : openBackupSession(homeDir, "onboard");
|
|
343
|
+
const syncReport = await collectSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
|
|
344
|
+
const mcpSyncReport = await collectMcpSyncReport({ homeDir, dryRun: opts.dryRun, managedAgents, backupSession });
|
|
345
|
+
backupSession?.finalize();
|
|
346
|
+
// Read-only, no dryRun concept — same report either way, run last since
|
|
347
|
+
// it audits the config mcp sync just wrote (or, on --dry-run, whatever
|
|
348
|
+
// was already there before this run).
|
|
349
|
+
const secretsAuditReport = await collectSecretsAuditReport({ homeDir, managedAgents });
|
|
350
|
+
return {
|
|
351
|
+
summary,
|
|
352
|
+
source,
|
|
353
|
+
sourceReason,
|
|
354
|
+
managedAgents,
|
|
355
|
+
installResults: installResults.length > 0 ? installResults : undefined,
|
|
356
|
+
migratePlan,
|
|
357
|
+
migrateSkipped,
|
|
358
|
+
syncReport,
|
|
359
|
+
mcpSyncReport,
|
|
360
|
+
secretsAuditReport,
|
|
361
|
+
};
|
|
103
362
|
}
|
|
104
363
|
export async function runOnboard(opts = {}) {
|
|
105
364
|
const result = await collectOnboardPlan(opts);
|
|
@@ -112,7 +371,9 @@ export async function runOnboard(opts = {}) {
|
|
|
112
371
|
if (result.refusal)
|
|
113
372
|
return { exitCode: 1 };
|
|
114
373
|
const hasConflict = (result.migratePlan?.items.some((i) => i.action === "conflict") ?? false) ||
|
|
115
|
-
(result.syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false)
|
|
374
|
+
(result.syncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false) ||
|
|
375
|
+
(result.mcpSyncReport?.reports.some((r) => r.items.some((i) => i.action === "conflict")) ?? false) ||
|
|
376
|
+
(result.secretsAuditReport?.findings.length ?? 0) > 0;
|
|
116
377
|
return { exitCode: hasConflict ? 1 : 0 };
|
|
117
378
|
}
|
|
118
379
|
function printResult(result, dryRun) {
|
|
@@ -130,15 +391,26 @@ function printResult(result, dryRun) {
|
|
|
130
391
|
console.error(result.refusal);
|
|
131
392
|
return;
|
|
132
393
|
}
|
|
133
|
-
if (result.
|
|
134
|
-
console.log(`Only ${result.
|
|
394
|
+
if (result.sourceReason === "auto-selected") {
|
|
395
|
+
console.log(`Only ${result.source} has real content — using it as the migration source.`);
|
|
135
396
|
}
|
|
136
|
-
else if (result.
|
|
137
|
-
console.log(`Using ${result.
|
|
397
|
+
else if (result.sourceReason === "flag") {
|
|
398
|
+
console.log(`Using ${result.source} as the migration source (--agent).`);
|
|
138
399
|
}
|
|
139
|
-
else if (result.
|
|
140
|
-
console.log(`Using ${result.
|
|
400
|
+
else if (result.sourceReason === "prompt") {
|
|
401
|
+
console.log(`Using ${result.source} as the migration source.`);
|
|
141
402
|
}
|
|
403
|
+
else {
|
|
404
|
+
console.log("No agent has real content to migrate from — starting from canonical's placeholder.");
|
|
405
|
+
}
|
|
406
|
+
for (const install of result.installResults ?? []) {
|
|
407
|
+
console.log(install.installed
|
|
408
|
+
? `Installed ${install.agent}.`
|
|
409
|
+
: install.installable
|
|
410
|
+
? `${install.agent} was not installed and the install was declined — left out of this run's managed set.`
|
|
411
|
+
: `${install.agent} has no npm package to install (see \`trellis init\`'s install hint) — left out of this run's managed set.`);
|
|
412
|
+
}
|
|
413
|
+
console.log(`Managed agents: ${result.managedAgents && result.managedAgents.length > 0 ? result.managedAgents.join(", ") : "(none)"}`);
|
|
142
414
|
// Reuse `migrate`/`sync`'s own printing verbatim (including the
|
|
143
415
|
// "nothing to migrate" / "already in sync" cases) rather than a second,
|
|
144
416
|
// easily-drifting copy of this formatting. `dryRun: false` here since
|
|
@@ -147,9 +419,20 @@ function printResult(result, dryRun) {
|
|
|
147
419
|
console.log("");
|
|
148
420
|
printMigratePlan(result.migratePlan, false);
|
|
149
421
|
}
|
|
422
|
+
else if (result.migrateSkipped) {
|
|
423
|
+
console.log("");
|
|
424
|
+
console.log(result.migrateSkipped);
|
|
425
|
+
}
|
|
150
426
|
if (result.syncReport) {
|
|
151
427
|
console.log("\nsync");
|
|
152
428
|
printSyncReport(result.syncReport, false);
|
|
153
429
|
}
|
|
154
|
-
|
|
430
|
+
if (result.mcpSyncReport) {
|
|
431
|
+
console.log("\nmcp sync");
|
|
432
|
+
printMcpSyncReport(result.mcpSyncReport, false);
|
|
433
|
+
}
|
|
434
|
+
if (result.secretsAuditReport) {
|
|
435
|
+
console.log("\nsecrets audit");
|
|
436
|
+
printSecretsAuditReport(result.secretsAuditReport);
|
|
437
|
+
}
|
|
155
438
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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 type { BackupManifest } from "../lib/backup.js";
|
|
11
|
+
export interface RunRollbackOptions {
|
|
12
|
+
runId?: string;
|
|
13
|
+
list?: boolean;
|
|
14
|
+
dryRun?: boolean;
|
|
15
|
+
json?: boolean;
|
|
16
|
+
/** Test/sandbox-only seam, same as every other command. Never a CLI
|
|
17
|
+
* flag. */
|
|
18
|
+
homeDir?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface BackupRunSummary {
|
|
21
|
+
runId: string;
|
|
22
|
+
command: string;
|
|
23
|
+
startedAt: string;
|
|
24
|
+
operationCount: number;
|
|
25
|
+
}
|
|
26
|
+
export interface RollbackPlanItem {
|
|
27
|
+
action: "restore" | "conflict" | "already-reverted";
|
|
28
|
+
path: string;
|
|
29
|
+
description: string;
|
|
30
|
+
}
|
|
31
|
+
export interface RollbackReport {
|
|
32
|
+
runId: string;
|
|
33
|
+
items: RollbackPlanItem[];
|
|
34
|
+
}
|
|
35
|
+
/** Newest first — run ids are ISO-timestamp-prefixed, so lexical sort is
|
|
36
|
+
* chronological sort. */
|
|
37
|
+
export declare function listBackups(homeDir?: string): BackupRunSummary[];
|
|
38
|
+
export declare function loadManifest(homeDir: string, runId: string): BackupManifest;
|
|
39
|
+
export declare function collectRollbackPlan(homeDirInput: string | undefined, runIdInput: string | undefined): Promise<RollbackReport>;
|
|
40
|
+
export declare function applyRollbackPlan(homeDir: string, runId: string, manifest: BackupManifest, items: RollbackPlanItem[]): Promise<void>;
|
|
41
|
+
export declare function runRollback(opts?: RunRollbackOptions): Promise<{
|
|
42
|
+
exitCode: number;
|
|
43
|
+
}>;
|
|
44
|
+
export declare function printReport(report: RollbackReport, dryRun: boolean): void;
|