@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.7 → 0.1.8
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/bin/adl +2 -2
- package/bin/lean-ctx-observe-strip +76 -3
- package/bin/reconcile +175 -5
- package/bin/validate-manifest +19 -1
- package/package.json +1 -1
- package/packages/agent-hooks/src/deploy/deploy.ts +98 -18
- package/packages/agent-hooks/src/deploy/doctor.ts +61 -11
- package/packages/agent-hooks/src/deploy/json-merge.ts +430 -18
- package/packages/agent-hooks/src/deploy/lock.ts +36 -2
- package/packages/agent-hooks/src/lean-ctx/env.ts +17 -0
- package/packages/agent-hooks/src/lean-ctx/mcp.ts +4 -2
- package/packages/agent-settings/assets/lean-ctx/config.toml +3 -0
- package/packages/agent-settings/assets/lean-ctx/gitignore +4 -0
- package/packages/agent-settings/assets/lean-ctx/layout.toml +2 -0
- package/packages/agent-settings/src/deploy/agents-deploy.ts +10 -5
- package/packages/agent-settings/src/deploy/doctor.ts +39 -3
- package/packages/agent-settings/src/deploy/lean-ctx-deploy.ts +104 -12
- package/packages/agent-settings/src/deploy/lean-ctx-merge.ts +580 -0
- package/packages/agent-settings/src/deploy/lock.ts +4 -1
- package/packages/agent-settings/src/deploy/mcp-deploy.ts +9 -6
- package/packages/agent-settings/src/deploy/settings.ts +6 -3
- package/packages/agent-skills/dist/deploy.d.ts +2 -0
- package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
- package/packages/agent-skills/dist/deploy.js +15 -8
- package/packages/agent-skills/dist/deploy.js.map +1 -1
- package/packages/agent-skills/dist/manifest.d.ts +1 -0
- package/packages/agent-skills/dist/manifest.d.ts.map +1 -1
- package/packages/agent-skills/dist/manifest.js +6 -2
- package/packages/agent-skills/dist/manifest.js.map +1 -1
- package/packages/agent-skills/src/deploy.ts +15 -8
- package/packages/agent-skills/src/manifest.ts +5 -2
- package/packages/azdo/src/azdo-repo-doctor.ts +401 -91
- package/packages/azdo/src/lib/graph-identity.ts +190 -3
- package/packages/fleet/jobs/git-maintain/src/main.ts +305 -45
- package/packages/fleet/manifest.json +2 -0
- package/packages/fleet/manifest.schema.json +9 -0
- package/packages/repo-controls/README.md +13 -6
- package/packages/repo-controls/assets/adl-config.default.toml +1 -0
- package/packages/repo-controls/src/cli.ts +6 -1
- package/packages/repo-controls/src/install.ts +88 -6
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The merge contract for a repo-local lean-ctx file, corrected by a
|
|
3
|
+
* SECOND independent review (2026-09-29) after the marker-region design
|
|
4
|
+
* itself shipped. Three real bugs found there:
|
|
5
|
+
*
|
|
6
|
+
* 3. HIGH — `shell_allowlist_extra` lived INSIDE the managed block, so a
|
|
7
|
+
* real `lean-ctx allow <cmd>` writing an entry into it was wiped by
|
|
8
|
+
* the very next deploy's self-heal (a whole-block replace). DECISION:
|
|
9
|
+
* the array leaves the managed block and becomes a UNION key —
|
|
10
|
+
* resolved by a narrow text scan (string literals only), adl's
|
|
11
|
+
* required entries inserted before `]` if missing, nothing ever
|
|
12
|
+
* removed or reordered.
|
|
13
|
+
* 4. HIGH — migration required the asset's CURRENT text to be found
|
|
14
|
+
* verbatim in the existing file. The asset changing at all (as it
|
|
15
|
+
* legitimately does, e.g. this same fix) broke migration for every
|
|
16
|
+
* already-enrolled repo, including this one. DECISION: migration now
|
|
17
|
+
* classifies the existing (markerless) file LINE BY LINE — a line
|
|
18
|
+
* matching a KNOWN adl key (`minimal_overhead`, ..., `mode`) is adl's
|
|
19
|
+
* own, replaced by the canonical value regardless of what it used to
|
|
20
|
+
* say; a blank line is dropped (formatting only); anything else —
|
|
21
|
+
* an unrecognized key, a table header, OR A COMMENT (finding 4-minor,
|
|
22
|
+
* independent review 2026-09-30, superseding this bullet's original
|
|
23
|
+
* "comments are dropped too" — see `migrateByKnownKeys`'s own doc
|
|
24
|
+
* comment for why) — is the user's, kept verbatim, in place, after
|
|
25
|
+
* the canonical block. This never depends on the asset's exact prior
|
|
26
|
+
* text, so a template change cannot break migration again.
|
|
27
|
+
* 5. MED — two blind spots: a quoted duplicate key AFTER the block, and
|
|
28
|
+
* a user table header BEFORE it (which silently turns adl's own keys
|
|
29
|
+
* into `<table>.<key>`), both passed as "unchanged" under pure
|
|
30
|
+
* text/region matching. DECISION: after composing (never
|
|
31
|
+
* re-serializing — this validates the ACTUAL COMPOSED TEXT, whether
|
|
32
|
+
* that text turned out unchanged, self-healed, or migrated), the
|
|
33
|
+
* WHOLE file is parsed with a real TOML parser (`Bun.TOML`, not
|
|
34
|
+
* `@iarna/toml` — TOML 0.5, rejects valid TOML 1.0) and every adl key
|
|
35
|
+
* must resolve AT THE TOP LEVEL to adl's own value; any parse
|
|
36
|
+
* failure or mismatch is a conflict, never a silent "unchanged".
|
|
37
|
+
* Independent review, 2026-09-30, finding 5-rest: this validation is
|
|
38
|
+
* only as good as `scalarValuesOf`'s own coverage — a version that
|
|
39
|
+
* recognized only `true`/`false` left layout.toml's own `mode = "xdg"`
|
|
40
|
+
* (a string) OUT of `expectedScalars` entirely, so a table header
|
|
41
|
+
* before ITS block passed silently. `scalarValuesOf` now parses every
|
|
42
|
+
* scalar shape a managed block can carry (bool, number, quoted
|
|
43
|
+
* string, simple string array), closing that gap for every target,
|
|
44
|
+
* not just config.toml's boolean keys.
|
|
45
|
+
*
|
|
46
|
+
* Marker-region mechanics (BEGIN/END, malformed markers, migration) are
|
|
47
|
+
* shared by every target — config.toml's scalar keys, layout.toml's
|
|
48
|
+
* whole content, and (with an append-not-migrate variant — see finding 6,
|
|
49
|
+
* `resolveGitignoreWrite`) the `.agents/.gitignore` block. config.toml
|
|
50
|
+
* additionally carries the union-key array outside its own block
|
|
51
|
+
* (`resolveConfigTomlWrite`).
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
export const LEAN_CTX_MARKER_START = '# >>> adl managed (do not edit within this block) >>>';
|
|
55
|
+
export const LEAN_CTX_MARKER_END = '# <<< adl managed <<<';
|
|
56
|
+
export const SHELL_ALLOWLIST_KEY = 'shell_allowlist_extra';
|
|
57
|
+
|
|
58
|
+
interface Region {
|
|
59
|
+
readonly start: number;
|
|
60
|
+
/** Exclusive — the index immediately after the closing marker's own text. */
|
|
61
|
+
readonly end: number;
|
|
62
|
+
}
|
|
63
|
+
type RegionResult = Region | 'missing' | 'malformed';
|
|
64
|
+
|
|
65
|
+
function findRegion(content: string): RegionResult {
|
|
66
|
+
const start = content.indexOf(LEAN_CTX_MARKER_START);
|
|
67
|
+
const stop = content.indexOf(LEAN_CTX_MARKER_END);
|
|
68
|
+
if (start < 0 && stop < 0) return 'missing';
|
|
69
|
+
if (start < 0 || stop < 0 || stop < start) return 'malformed';
|
|
70
|
+
return { start, end: stop + LEAN_CTX_MARKER_END.length };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The block's own content between (and including) its markers, no trailing newline. */
|
|
74
|
+
function canonicalBlockOf(assetText: string): string {
|
|
75
|
+
return assetText.replace(/\n+$/, '');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function spliceRegion(content: string, region: Region, canonicalBlock: string): string {
|
|
79
|
+
return `${content.slice(0, region.start)}${canonicalBlock}${content.slice(region.end)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `key = ...` names declared directly inside `canonicalBlock` — a plain line scan, not a TOML parse (this module never parses to decide what to write; only to VALIDATE the result — see `validateComposedToml`). */
|
|
83
|
+
function declaredKeyNames(canonicalBlock: string): readonly string[] {
|
|
84
|
+
const names: string[] = [];
|
|
85
|
+
for (const line of canonicalBlock.split('\n')) {
|
|
86
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
87
|
+
if (match) names.push(match[1] as string);
|
|
88
|
+
}
|
|
89
|
+
return names;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function duplicateKeyOutsideRegion(content: string, region: Region, canonicalBlock: string): string | undefined {
|
|
93
|
+
const outside = content.slice(0, region.start) + content.slice(region.end);
|
|
94
|
+
for (const name of declaredKeyNames(canonicalBlock)) {
|
|
95
|
+
if (new RegExp(`^\\s*${name}\\s*=`, 'm').test(outside)) return name;
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Migration (finding 4): classifies a markerless file the lock already
|
|
102
|
+
* says adl wrote, line by line, against `knownKeys` for THIS target.
|
|
103
|
+
* Never depends on the asset's own exact prior text — a `key = value`
|
|
104
|
+
* line naming a known key is adl's own (dropped; the canonical block
|
|
105
|
+
* replaces it outright, whatever it used to say); a blank line is
|
|
106
|
+
* dropped (formatting only); anything else — including a full-line
|
|
107
|
+
* comment — is kept verbatim, IN ORDER, as the user's.
|
|
108
|
+
*
|
|
109
|
+
* A comment is deliberately NOT dropped (independent review, 2026-09-30,
|
|
110
|
+
* finding 4-minor — this superseded an earlier version that dropped
|
|
111
|
+
* every comment line as "adl's own old commentary, not reliably told
|
|
112
|
+
* apart from a user's by text alone"): dropping a genuine user comment
|
|
113
|
+
* is permanent, silent data loss; keeping an old adl comment as harmless
|
|
114
|
+
* extra text next to the canonical block (which carries its own fresh
|
|
115
|
+
* comments regardless) costs nothing. When in doubt, keep it — the same
|
|
116
|
+
* rule this module already applies to every OTHER unrecognized line.
|
|
117
|
+
*
|
|
118
|
+
* `recognizedCount` is how many lines matched a known key — the signal
|
|
119
|
+
* that decides whether migration is safe AT ALL (`resolveLeanCtxWrite`'s
|
|
120
|
+
* own caller): a file with at least one recognizable adl key is
|
|
121
|
+
* plausibly adl's own prior content, whatever else is wrong with it; a
|
|
122
|
+
* file with NONE (a whole-file clobber — `"tampered\n"`, Tarun 2026-09-29,
|
|
123
|
+
* D.b: this exact scenario is a conflict, not a guess) has no evidence
|
|
124
|
+
* left to migrate from at all.
|
|
125
|
+
*/
|
|
126
|
+
function migrateByKnownKeys(content: string, knownKeys: ReadonlySet<string>): { readonly kept: readonly string[]; readonly recognizedCount: number } {
|
|
127
|
+
const kept: string[] = [];
|
|
128
|
+
let recognizedCount = 0;
|
|
129
|
+
for (const line of content.split('\n')) {
|
|
130
|
+
const trimmed = line.trim();
|
|
131
|
+
if (trimmed.length === 0) continue;
|
|
132
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(trimmed);
|
|
133
|
+
if (match && knownKeys.has(match[1] as string)) {
|
|
134
|
+
recognizedCount += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
kept.push(line);
|
|
138
|
+
}
|
|
139
|
+
return { kept, recognizedCount };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** One adl-owned scalar whose value differed and was restored to adl's own (2026-09-30, Tarun's merge-contract correction: "adl-owned entries SELF-HEAL" — a differing value is drift, never a conflict). Named on the write outcome so deploy/doctor can report it explicitly, the same way a healed hook is named. */
|
|
143
|
+
export interface RestoredScalar {
|
|
144
|
+
readonly key: string;
|
|
145
|
+
readonly value: unknown;
|
|
146
|
+
readonly previous: unknown;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type LeanCtxWriteOutcome =
|
|
150
|
+
| { readonly action: 'unchanged' }
|
|
151
|
+
| { readonly action: 'write'; readonly text: string; readonly restoredScalars?: readonly RestoredScalar[] }
|
|
152
|
+
| { readonly action: 'conflict'; readonly reason: string };
|
|
153
|
+
|
|
154
|
+
/** Real TOML 1.0 parse (Bun's built-in, not `@iarna/toml` — TOML 0.5) — read-only validation, never used to decide what to write. */
|
|
155
|
+
function parseToml(text: string): Record<string, unknown> | 'invalid' {
|
|
156
|
+
try {
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Bun global, no @types/bun in this package's own deps
|
|
158
|
+
const doc = (globalThis as any).Bun.TOML.parse(text);
|
|
159
|
+
return typeof doc === 'object' && doc !== null && !Array.isArray(doc) ? (doc as Record<string, unknown>) : 'invalid';
|
|
160
|
+
} catch {
|
|
161
|
+
return 'invalid';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** True iff `actual` matches `expected`, where `expected` may be a scalar (compared by `===`) or an array (compared element-by-element, same length and order). */
|
|
166
|
+
function tomlValueMatches(actual: unknown, expected: unknown): boolean {
|
|
167
|
+
if (Array.isArray(expected)) {
|
|
168
|
+
return Array.isArray(actual) && actual.length === expected.length && expected.every((entry, i) => actual[i] === entry);
|
|
169
|
+
}
|
|
170
|
+
return actual === expected;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Finding 5's safety net: parses the COMPOSED text (whatever this module
|
|
175
|
+
* is about to call "unchanged" or "write") and confirms every one of
|
|
176
|
+
* `expectedScalars` resolves at the TOP LEVEL to its expected value, and
|
|
177
|
+
* (when `expectedArrayEntries` is given) `arrayKey` resolves to an array
|
|
178
|
+
* containing every one of them. A duplicate key elsewhere in the file, or
|
|
179
|
+
* a table header before the managed block turning adl's own keys into
|
|
180
|
+
* `<table>.<key>`, fails this — a conflict, never a silent pass.
|
|
181
|
+
*/
|
|
182
|
+
function validateComposedToml(
|
|
183
|
+
text: string,
|
|
184
|
+
expectedScalars: Readonly<Record<string, unknown>>,
|
|
185
|
+
arrayKey?: string,
|
|
186
|
+
expectedArrayEntries?: readonly string[],
|
|
187
|
+
): string | undefined {
|
|
188
|
+
const doc = parseToml(text);
|
|
189
|
+
if (doc === 'invalid') return 'the composed file is not valid TOML';
|
|
190
|
+
for (const [key, value] of Object.entries(expectedScalars)) {
|
|
191
|
+
if (!tomlValueMatches(doc[key], value)) return `"${key}" does not resolve to adl's value at the top level (a table header before the managed block, or a duplicate key, is shadowing it)`;
|
|
192
|
+
}
|
|
193
|
+
if (arrayKey !== undefined && expectedArrayEntries !== undefined) {
|
|
194
|
+
const actual = doc[arrayKey];
|
|
195
|
+
if (!Array.isArray(actual)) return `"${arrayKey}" does not resolve to an array at the top level`;
|
|
196
|
+
const missing = expectedArrayEntries.filter((entry) => !actual.includes(entry));
|
|
197
|
+
if (missing.length > 0) return `"${arrayKey}" is missing adl-required entries: ${missing.join(', ')}`;
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Parses a single-line TOML scalar value — bool, integer/float, quoted
|
|
204
|
+
* string, or a simple (string-literals-only) inline array — or returns
|
|
205
|
+
* `undefined` for anything else (a multi-line value, a table, etc.),
|
|
206
|
+
* which `scalarValuesOf` then simply skips rather than guessing.
|
|
207
|
+
*/
|
|
208
|
+
function parseTomlScalarValue(raw: string): unknown {
|
|
209
|
+
const trimmed = raw.trim();
|
|
210
|
+
if (trimmed === 'true') return true;
|
|
211
|
+
if (trimmed === 'false') return false;
|
|
212
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
213
|
+
const stringMatch = /^"((?:[^"\\]|\\.)*)"$/.exec(trimmed);
|
|
214
|
+
if (stringMatch) return (stringMatch[1] as string).replace(/\\(.)/g, '$1');
|
|
215
|
+
const arrayMatch = /^\[(.*)\]$/.exec(trimmed);
|
|
216
|
+
if (arrayMatch) {
|
|
217
|
+
const entries = parseSimpleStringArrayBody(arrayMatch[1] as string);
|
|
218
|
+
if (entries !== 'malformed') return entries;
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Every `key = value` line's value, parsed generically (finding 5-rest,
|
|
225
|
+
* independent review 2026-09-30 — this used to recognize `true`/`false`
|
|
226
|
+
* only, so a STRING-valued key like layout.toml's own `mode = "xdg"` was
|
|
227
|
+
* silently excluded from `validateComposedToml`'s `expectedScalars`
|
|
228
|
+
* entirely, leaving that target's own table-header-before-block blind
|
|
229
|
+
* spot uncaught; see this file's header for the full history). A value
|
|
230
|
+
* this module cannot parse (a multi-line construct) is simply skipped,
|
|
231
|
+
* never guessed at.
|
|
232
|
+
*/
|
|
233
|
+
function scalarValuesOf(canonicalBlock: string): Record<string, unknown> {
|
|
234
|
+
const values: Record<string, unknown> = {};
|
|
235
|
+
for (const line of canonicalBlock.split('\n')) {
|
|
236
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+)$/.exec(line.trim());
|
|
237
|
+
if (!match) continue;
|
|
238
|
+
const value = parseTomlScalarValue(match[2] as string);
|
|
239
|
+
if (value !== undefined) values[match[1] as string] = value;
|
|
240
|
+
}
|
|
241
|
+
return values;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Marker-region-only target (layout.toml): the whole file's meaningful
|
|
246
|
+
* content is inside the block. Callers only reach this once
|
|
247
|
+
* `currentText !== assetText` — an exact match is always `unchanged`.
|
|
248
|
+
*/
|
|
249
|
+
export function resolveLeanCtxWrite(assetText: string, currentText: string, wasWrittenByUs: boolean): LeanCtxWriteOutcome {
|
|
250
|
+
const canonicalBlock = canonicalBlockOf(assetText);
|
|
251
|
+
const region = findRegion(currentText);
|
|
252
|
+
|
|
253
|
+
if (region === 'malformed') {
|
|
254
|
+
return { action: 'conflict', reason: 'managed block markers are malformed (BEGIN/END unpaired or out of order); left untouched' };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (region === 'missing') {
|
|
258
|
+
if (!wasWrittenByUs) {
|
|
259
|
+
return { action: 'conflict', reason: 'no adl-managed block found in this file, and it is not recorded as adl-written; left untouched' };
|
|
260
|
+
}
|
|
261
|
+
const knownKeys = new Set(declaredKeyNames(canonicalBlock));
|
|
262
|
+
const { kept, recognizedCount } = migrateByKnownKeys(currentText, knownKeys);
|
|
263
|
+
if (recognizedCount === 0) {
|
|
264
|
+
return { action: 'conflict', reason: 'no recognizable adl-owned content found to migrate; left untouched' };
|
|
265
|
+
}
|
|
266
|
+
const composed = kept.length > 0 ? `${canonicalBlock}\n\n${kept.join('\n').replace(/\n+$/, '')}\n` : `${canonicalBlock}\n`;
|
|
267
|
+
const invalid = validateComposedToml(composed, scalarValuesOf(canonicalBlock));
|
|
268
|
+
if (invalid !== undefined) return { action: 'conflict', reason: `cannot migrate safely: ${invalid}` };
|
|
269
|
+
return { action: 'write', text: composed };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const duplicate = duplicateKeyOutsideRegion(currentText, region, canonicalBlock);
|
|
273
|
+
if (duplicate !== undefined) {
|
|
274
|
+
return { action: 'conflict', reason: `"${duplicate}" is declared both inside and outside the adl-managed block; left untouched` };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const installed = currentText.slice(region.start, region.end);
|
|
278
|
+
const composed = installed === canonicalBlock ? currentText : spliceRegion(currentText, region, canonicalBlock);
|
|
279
|
+
const invalid = validateComposedToml(composed, scalarValuesOf(canonicalBlock));
|
|
280
|
+
if (invalid !== undefined) return { action: 'conflict', reason: invalid };
|
|
281
|
+
return installed === canonicalBlock ? { action: 'unchanged' } : { action: 'write', text: composed };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** A simple (string-literals-only) array's parsed entries, or 'malformed' if it contains anything else (a number, a nested array, a bare identifier). */
|
|
285
|
+
function parseSimpleStringArrayBody(body: string): readonly string[] | 'malformed' {
|
|
286
|
+
const trimmed = body.trim();
|
|
287
|
+
if (trimmed.length === 0) return [];
|
|
288
|
+
const entries: string[] = [];
|
|
289
|
+
for (const piece of trimmed.split(',').map((p) => p.trim())) {
|
|
290
|
+
if (piece.length === 0) continue;
|
|
291
|
+
const match = /^"((?:[^"\\]|\\.)*)"$/.exec(piece);
|
|
292
|
+
if (!match) return 'malformed';
|
|
293
|
+
entries.push(match[1] as string);
|
|
294
|
+
}
|
|
295
|
+
return entries;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
interface ArraySpan {
|
|
299
|
+
readonly start: number;
|
|
300
|
+
readonly end: number;
|
|
301
|
+
readonly body: string;
|
|
302
|
+
}
|
|
303
|
+
function findArray(text: string, key: string): ArraySpan | 'missing' | 'malformed' {
|
|
304
|
+
const startMatch = new RegExp(`^${key}\\s*=\\s*\\[`, 'm').exec(text);
|
|
305
|
+
if (!startMatch) return 'missing';
|
|
306
|
+
const openBracket = startMatch.index + startMatch[0].length - 1;
|
|
307
|
+
const closeBracket = text.indexOf(']', openBracket);
|
|
308
|
+
if (closeBracket === -1) return 'malformed';
|
|
309
|
+
const body = text.slice(openBracket + 1, closeBracket);
|
|
310
|
+
if (body.includes('[')) return 'malformed'; // a nested array -- not "a simple string array"
|
|
311
|
+
return { start: startMatch.index, end: closeBracket + 1, body };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Text-splices `missingEntries` into an existing simple array's body, right before its closing `]`, never touching what is already there. */
|
|
315
|
+
function insertMissingEntries(text: string, array: ArraySpan, missingEntries: readonly string[]): string {
|
|
316
|
+
const closeBracketIndex = array.end - 1; // position of ']'
|
|
317
|
+
const bodyTrimmedEnd = text.slice(array.start, closeBracketIndex).replace(/\s+$/, '');
|
|
318
|
+
const needsLeadingComma = /[^\s[]$/.test(bodyTrimmedEnd) && !bodyTrimmedEnd.endsWith(',');
|
|
319
|
+
const insertion = `${needsLeadingComma ? ',' : ''}\n${missingEntries.map((e) => ` "${e}",`).join('\n')}\n`;
|
|
320
|
+
return `${text.slice(0, closeBracketIndex)}${insertion}${text.slice(closeBracketIndex)}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The portion of `text` that TOML grammar guarantees is ALL bare
|
|
325
|
+
* (top-level) key/value pairs — everything up to (not including) the
|
|
326
|
+
* first `[table]` or `[[array-of-tables]]` header. A key/value LINE
|
|
327
|
+
* found inside this prefix is unambiguously top-level; the same key name
|
|
328
|
+
* appearing after a table header belongs to that table instead
|
|
329
|
+
* (`table.key`, a completely different key), which a pure line/regex
|
|
330
|
+
* scan cannot tell apart from a top-level line on its own — this keeps
|
|
331
|
+
* that distinction correct without needing to track table context.
|
|
332
|
+
*/
|
|
333
|
+
function topLevelPrefixEnd(text: string): number {
|
|
334
|
+
const match = /^\[/m.exec(text);
|
|
335
|
+
return match ? match.index : text.length;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Removes the (top-level-only, see `topLevelPrefixEnd`) `key = value`
|
|
340
|
+
* line for each key in `keys` from `text` — used for every adl-owned
|
|
341
|
+
* scalar key the current file ALREADY declares at the top level, whether
|
|
342
|
+
* its value matches adl's canonical one or not: the FULL canonical block
|
|
343
|
+
* (always inserted whole, never partial — every other reader of this
|
|
344
|
+
* module assumes the managed block IS the complete canonical text) is
|
|
345
|
+
* about to declare it again, and TOML forbids a duplicate top-level key
|
|
346
|
+
* regardless of whether the two values agree. This is not "deleting a
|
|
347
|
+
* user's line" in the sense the merge decision forbids — a matching
|
|
348
|
+
* value is unchanged and un-lost, merely consolidated into the block
|
|
349
|
+
* exactly where an ordinary deploy would have put it; a DIFFERING value
|
|
350
|
+
* is adl-owned drift, and adl's own merge contract ("adl-owned entries
|
|
351
|
+
* SELF-HEAL") already says adl's value wins — the caller names the
|
|
352
|
+
* restore (`RestoredScalar`) so it is never silent.
|
|
353
|
+
*/
|
|
354
|
+
function removeTopLevelScalarLines(text: string, keys: ReadonlySet<string>): string {
|
|
355
|
+
if (keys.size === 0) return text;
|
|
356
|
+
const prefixEnd = topLevelPrefixEnd(text);
|
|
357
|
+
const prefix = text.slice(0, prefixEnd);
|
|
358
|
+
const rest = text.slice(prefixEnd);
|
|
359
|
+
const keptPrefixLines = prefix.split('\n').filter((line) => {
|
|
360
|
+
const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line.trim());
|
|
361
|
+
return match === null || !keys.has(match[1] as string);
|
|
362
|
+
});
|
|
363
|
+
return `${keptPrefixLines.join('\n')}${rest}`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* config.toml specifically (finding 3 + 4 + 5, and MERGED entirely,
|
|
368
|
+
* Tarun 2026-09-30 — see this function's own migration branch, below,
|
|
369
|
+
* and the describe block in lean-ctx-merge-contract.test.ts headed
|
|
370
|
+
* "config.toml's allow list is MERGED entry-by-entry" for the full
|
|
371
|
+
* decision): its own scalar keys live in the marker-region block exactly
|
|
372
|
+
* like `resolveLeanCtxWrite`, but `shell_allowlist_extra` lives OUTSIDE
|
|
373
|
+
* the markers as a UNION key — located anywhere in the file by a narrow
|
|
374
|
+
* text scan, adl's required entries inserted before `]` if missing,
|
|
375
|
+
* nothing ever removed/reordered. A non-simple array (anything but
|
|
376
|
+
* string literals) is a conflict.
|
|
377
|
+
*/
|
|
378
|
+
export function resolveConfigTomlWrite(assetText: string, currentText: string, wasWrittenByUs: boolean): LeanCtxWriteOutcome {
|
|
379
|
+
const assetArray = findArray(assetText, SHELL_ALLOWLIST_KEY);
|
|
380
|
+
if (assetArray === 'missing' || assetArray === 'malformed') {
|
|
381
|
+
throw new Error(`agent-settings lean-ctx config.toml asset itself has no valid "${SHELL_ALLOWLIST_KEY}" array — this is a packaging bug, not a target-repo conflict`);
|
|
382
|
+
}
|
|
383
|
+
const requiredEntries = parseSimpleStringArrayBody(assetArray.body);
|
|
384
|
+
if (requiredEntries === 'malformed') {
|
|
385
|
+
throw new Error(`agent-settings lean-ctx config.toml asset's own "${SHELL_ALLOWLIST_KEY}" array is not a simple string array — this is a packaging bug, not a target-repo conflict`);
|
|
386
|
+
}
|
|
387
|
+
const canonicalBlock = canonicalBlockOf(assetText.slice(0, assetArray.start));
|
|
388
|
+
const scalarValues = scalarValuesOf(canonicalBlock);
|
|
389
|
+
|
|
390
|
+
let working = currentText;
|
|
391
|
+
const region = findRegion(working);
|
|
392
|
+
if (region === 'malformed') return { action: 'conflict', reason: 'managed block markers are malformed (BEGIN/END unpaired or out of order); left untouched' };
|
|
393
|
+
|
|
394
|
+
if (region === 'missing') {
|
|
395
|
+
if (!wasWrittenByUs) return { action: 'conflict', reason: 'no adl-managed block found in this file, and it is not recorded as adl-written; left untouched' };
|
|
396
|
+
|
|
397
|
+
// Tarun, 2026-09-30 (corrected the same day — Tarun's merge contract
|
|
398
|
+
// is "adl-owned entries SELF-HEAL; user additions are kept"): "Merge
|
|
399
|
+
// the allow list in config.toml, the standard allow list must be
|
|
400
|
+
// checked entry by entry, missing should be added, user entries must
|
|
401
|
+
// not be removed." A markerless file is a MERGE target whenever it
|
|
402
|
+
// is valid TOML at all — even a WHOLE-FILE overwrite with the user's
|
|
403
|
+
// own unrelated content — never a conflict. An adl-owned scalar
|
|
404
|
+
// already present at the top level ALWAYS self-heals to adl's own
|
|
405
|
+
// value (an older asset default, or a user edit, is drift, not a
|
|
406
|
+
// conflict — otherwise every already-enrolled repo carrying an older
|
|
407
|
+
// adl default would conflict on upgrade); the restore is named
|
|
408
|
+
// (`restoredScalars`, below) so it is never silent. Conflict remains
|
|
409
|
+
// ONLY where merging is genuinely unsafe: invalid TOML (nowhere to
|
|
410
|
+
// safely place a block), or an allow-list value that isn't a simple
|
|
411
|
+
// string array. Nothing of the user's is ever deleted or reordered.
|
|
412
|
+
const parsedCurrent = parseToml(currentText);
|
|
413
|
+
if (parsedCurrent === 'invalid') {
|
|
414
|
+
return { action: 'conflict', reason: 'current file is not valid TOML; cannot place the managed block safely' };
|
|
415
|
+
}
|
|
416
|
+
if (Object.prototype.hasOwnProperty.call(parsedCurrent, SHELL_ALLOWLIST_KEY) && !Array.isArray(parsedCurrent[SHELL_ALLOWLIST_KEY])) {
|
|
417
|
+
return { action: 'conflict', reason: `"${SHELL_ALLOWLIST_KEY}" is not a simple string array; cannot merge safely` };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Every adl-owned scalar key the current file ALREADY declares at
|
|
421
|
+
// the top level — matching value or not — is removed from its
|
|
422
|
+
// original line: the FULL canonical block (always inserted whole; a
|
|
423
|
+
// "partial" block would make the managed-region invariant every
|
|
424
|
+
// other code path relies on unstable across repeated runs) is about
|
|
425
|
+
// to declare it again, and TOML forbids a duplicate top-level key
|
|
426
|
+
// regardless of whether the two values agree. A DIFFERING value is
|
|
427
|
+
// named in `restoredScalars` — adl's own value always wins.
|
|
428
|
+
const presentKeys = new Set(Object.keys(scalarValues).filter((key) => Object.prototype.hasOwnProperty.call(parsedCurrent, key)));
|
|
429
|
+
const restoredScalars: RestoredScalar[] = [...presentKeys]
|
|
430
|
+
.filter((key) => !tomlValueMatches(parsedCurrent[key], scalarValues[key]))
|
|
431
|
+
.map((key) => ({ key, value: scalarValues[key], previous: parsedCurrent[key] }));
|
|
432
|
+
const restOfFile = removeTopLevelScalarLines(currentText, presentKeys).replace(/^\n+/, '');
|
|
433
|
+
|
|
434
|
+
// Insert AT THE TOP, before any table header — always syntactically
|
|
435
|
+
// safe for valid TOML (bare key/value pairs may appear anywhere
|
|
436
|
+
// before the first table), and scalar collisions are already ruled
|
|
437
|
+
// out above.
|
|
438
|
+
let composed = restOfFile.length > 0 ? `${canonicalBlock}\n\n${restOfFile}` : `${canonicalBlock}\n`;
|
|
439
|
+
|
|
440
|
+
const arraySpan = findArray(composed, SHELL_ALLOWLIST_KEY);
|
|
441
|
+
if (arraySpan === 'malformed') {
|
|
442
|
+
return { action: 'conflict', reason: `"${SHELL_ALLOWLIST_KEY}" is not a simple string array; cannot merge safely` };
|
|
443
|
+
}
|
|
444
|
+
if (arraySpan === 'missing') {
|
|
445
|
+
// The user has no such array at all -- create it, with just the
|
|
446
|
+
// standard entries, right after the inserted block.
|
|
447
|
+
const arrayText = `${SHELL_ALLOWLIST_KEY} = [\n${requiredEntries.map((e) => ` "${e}",`).join('\n')}\n]\n`;
|
|
448
|
+
composed = `${canonicalBlock}\n${arrayText}\n${restOfFile}`;
|
|
449
|
+
} else {
|
|
450
|
+
const currentEntries = parseSimpleStringArrayBody(arraySpan.body);
|
|
451
|
+
if (currentEntries === 'malformed') {
|
|
452
|
+
return { action: 'conflict', reason: `"${SHELL_ALLOWLIST_KEY}" is not a simple string array; cannot merge safely` };
|
|
453
|
+
}
|
|
454
|
+
// Entry by entry: only the STANDARD entries missing from the
|
|
455
|
+
// user's OWN array are appended, at the end, byte-for-byte -- the
|
|
456
|
+
// user's own entries, and their order, are never touched.
|
|
457
|
+
const missingEntries = requiredEntries.filter((entry) => !currentEntries.includes(entry));
|
|
458
|
+
if (missingEntries.length > 0) composed = insertMissingEntries(composed, arraySpan, missingEntries);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const invalid = validateComposedToml(composed, scalarValues, SHELL_ALLOWLIST_KEY, requiredEntries);
|
|
462
|
+
if (invalid !== undefined) return { action: 'conflict', reason: `cannot merge safely: ${invalid}` };
|
|
463
|
+
return restoredScalars.length > 0 ? { action: 'write', text: composed, restoredScalars } : { action: 'write', text: composed };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const duplicate = duplicateKeyOutsideRegion(working, region, canonicalBlock);
|
|
467
|
+
if (duplicate !== undefined) return { action: 'conflict', reason: `"${duplicate}" is declared both inside and outside the adl-managed block; left untouched` };
|
|
468
|
+
|
|
469
|
+
const installedBlock = working.slice(region.start, region.end);
|
|
470
|
+
if (installedBlock !== canonicalBlock) working = spliceRegion(working, region, canonicalBlock);
|
|
471
|
+
|
|
472
|
+
// Step 2: the union array, resolved fresh against the (possibly just-healed) text.
|
|
473
|
+
const currentArray = findArray(working, SHELL_ALLOWLIST_KEY);
|
|
474
|
+
if (currentArray === 'missing') return { action: 'conflict', reason: `expected "${SHELL_ALLOWLIST_KEY}" array not found` };
|
|
475
|
+
if (currentArray === 'malformed') return { action: 'conflict', reason: `"${SHELL_ALLOWLIST_KEY}" is not a simple string array (contains something other than string literals)` };
|
|
476
|
+
const currentEntries = parseSimpleStringArrayBody(currentArray.body);
|
|
477
|
+
if (currentEntries === 'malformed') return { action: 'conflict', reason: `"${SHELL_ALLOWLIST_KEY}" is not a simple string array (contains something other than string literals)` };
|
|
478
|
+
const missing = requiredEntries.filter((entry) => !currentEntries.includes(entry));
|
|
479
|
+
if (missing.length > 0) working = insertMissingEntries(working, currentArray, missing);
|
|
480
|
+
|
|
481
|
+
if (working === currentText) {
|
|
482
|
+
const invalid = validateComposedToml(working, scalarValues, SHELL_ALLOWLIST_KEY, requiredEntries);
|
|
483
|
+
if (invalid !== undefined) return { action: 'conflict', reason: invalid };
|
|
484
|
+
return { action: 'unchanged' };
|
|
485
|
+
}
|
|
486
|
+
const invalid = validateComposedToml(working, scalarValues, SHELL_ALLOWLIST_KEY, requiredEntries);
|
|
487
|
+
if (invalid !== undefined) return { action: 'conflict', reason: invalid };
|
|
488
|
+
return { action: 'write', text: working };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** The block's own ignore-pattern lines (no markers, no comments) — what "already present" is checked against: a negation of one of these BEFORE an existing block is a conflict (round 5), and a bare duplicate of one of these OUTSIDE an existing block is ambiguous and stays a conflict too. Round 5 (Tarun, 2026-09-30) stopped using this to DROP lines during migration — see `resolveGitignoreWrite`'s own header for why. */
|
|
492
|
+
function gitignorePatternLines(canonicalBlock: string): readonly string[] {
|
|
493
|
+
return canonicalBlock
|
|
494
|
+
.split('\n')
|
|
495
|
+
.map((l) => l.trim())
|
|
496
|
+
.filter((l) => l.length > 0 && l !== LEAN_CTX_MARKER_START && l !== LEAN_CTX_MARKER_END && !l.startsWith('#'));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* `.agents/.gitignore` (finding 6, REDECIDED 2026-09-30 — a real repro
|
|
501
|
+
* showed the round-3 fix never actually ran: `lean-ctx-deploy.ts`'s own
|
|
502
|
+
* top-level `exists && !managedByUs` guard short-circuited to a hard
|
|
503
|
+
* conflict before this function was even called, and this function's
|
|
504
|
+
* OWN `!wasWrittenByUs` check would have refused it too). A markerless
|
|
505
|
+
* `.gitignore` is no longer a hard conflict EITHER WAY — written by adl
|
|
506
|
+
* before, or a plain user file that has simply never been touched.
|
|
507
|
+
*
|
|
508
|
+
* ROUND 5 (fourth independent review, same day) found the FIRST fix's
|
|
509
|
+
* own ordering bug: it deleted any existing line that happened to match
|
|
510
|
+
* one of adl's own patterns, then appended the canonical block at the
|
|
511
|
+
* END. A user's `*.bak` (redundant with adl's own pattern) followed by
|
|
512
|
+
* their OWN deliberate `!keep.bak` override got the `*.bak` line
|
|
513
|
+
* deleted, and adl's later copy of it (inside the appended block) then
|
|
514
|
+
* silently outranked `!keep.bak` — gitignore's last-match-wins rule
|
|
515
|
+
* turned the user's override backwards.
|
|
516
|
+
*
|
|
517
|
+
* DECISION (Tarun, 2026-09-30): never delete or reorder a user's own
|
|
518
|
+
* line, full stop — not even one that looks exactly like adl's own
|
|
519
|
+
* pattern (the cosmetic cost of a harmless duplicate line is strictly
|
|
520
|
+
* better than ever risking a real one). For a markerless file, the
|
|
521
|
+
* canonical block is PREPENDED instead of appended: every existing line
|
|
522
|
+
* (all of them, unmodified, in their original order) then comes AFTER
|
|
523
|
+
* the block and keeps winning, including any negation — exactly
|
|
524
|
+
* gitignore's own "last match wins" semantics, by construction, with no
|
|
525
|
+
* special-casing needed for negations in this branch at all (a
|
|
526
|
+
* pre-existing negation is now IMPOSSIBLE to place before the block,
|
|
527
|
+
* since the block is always first).
|
|
528
|
+
*
|
|
529
|
+
* For an EXISTING marked file, the block never moves from wherever it
|
|
530
|
+
* already sits — but a NEW negation added BEFORE it (which the block's
|
|
531
|
+
* own later copy of that pattern would silently override) is its own
|
|
532
|
+
* conflict, reported rather than silently accepted. A negation added
|
|
533
|
+
* AFTER the block already wins correctly and is not a conflict.
|
|
534
|
+
*
|
|
535
|
+
* The "bare duplicate outside the block" check (below) is scoped to
|
|
536
|
+
* BEFORE the block only — found while verifying this very fix against
|
|
537
|
+
* this repo's own REAL data, not asked for by name: a markerless
|
|
538
|
+
* migration keeps every existing line, including ones that happen to be
|
|
539
|
+
* adl's own canonical patterns (this repo's own actual pre-marker
|
|
540
|
+
* `.agents/.gitignore` is exactly that shape), and those necessarily end
|
|
541
|
+
* up AFTER the newly-prepended block. A duplicate AFTER the block is
|
|
542
|
+
* provably inert (gitignore's last-match-wins rule makes a trailing
|
|
543
|
+
* repeat of the same pattern a no-op), so checking it unscoped turned
|
|
544
|
+
* every such migration into a conflict on EVERY subsequent deploy
|
|
545
|
+
* forever. A duplicate BEFORE the block is the one that still matters:
|
|
546
|
+
* it is exactly the negation risk above, generalized to a bare
|
|
547
|
+
* (non-negated) repeat that could still be reordered or edited later in
|
|
548
|
+
* a way the block's own later copy would silently shadow.
|
|
549
|
+
*/
|
|
550
|
+
export function resolveGitignoreWrite(assetText: string, currentText: string, _wasWrittenByUs: boolean): LeanCtxWriteOutcome {
|
|
551
|
+
const canonicalBlock = canonicalBlockOf(assetText);
|
|
552
|
+
const region = findRegion(currentText);
|
|
553
|
+
if (region === 'malformed') return { action: 'conflict', reason: 'managed block markers are malformed (BEGIN/END unpaired or out of order); left untouched' };
|
|
554
|
+
|
|
555
|
+
const patternLines = new Set(gitignorePatternLines(canonicalBlock));
|
|
556
|
+
|
|
557
|
+
if (region === 'missing') {
|
|
558
|
+
const trimmed = currentText.replace(/\n+$/, '');
|
|
559
|
+
return { action: 'write', text: trimmed.length > 0 ? `${canonicalBlock}\n\n${trimmed}\n` : `${canonicalBlock}\n` };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const beforeLines = new Set(
|
|
563
|
+
currentText
|
|
564
|
+
.slice(0, region.start)
|
|
565
|
+
.split('\n')
|
|
566
|
+
.map((l) => l.trim()),
|
|
567
|
+
);
|
|
568
|
+
for (const pattern of patternLines) {
|
|
569
|
+
if (beforeLines.has(`!${pattern}`)) {
|
|
570
|
+
return { action: 'conflict', reason: `"!${pattern}" appears before the managed block and would be silently overridden by the block's own later "${pattern}"; left untouched` };
|
|
571
|
+
}
|
|
572
|
+
if (beforeLines.has(pattern)) {
|
|
573
|
+
return { action: 'conflict', reason: `"${pattern}" already appears before the managed block; left untouched` };
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const installed = currentText.slice(region.start, region.end);
|
|
578
|
+
if (installed === canonicalBlock) return { action: 'unchanged' };
|
|
579
|
+
return { action: 'write', text: spliceRegion(currentText, region, canonicalBlock) };
|
|
580
|
+
}
|
|
@@ -89,10 +89,13 @@ export function readLock(targetRepo: string): SettingsLock {
|
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/** Skips the write entirely when the serialized content is already byte-identical (independent review, 2026-09-29, finding 8: a genuine no-op run must not touch the lock file's mtime either). */
|
|
92
93
|
export function writeLock(targetRepo: string, lock: SettingsLock): void {
|
|
93
94
|
const path = lockPathFor(targetRepo);
|
|
95
|
+
const next = `${JSON.stringify({ schema: LOCK_SCHEMA, mcp: lock.mcp, agents: lock.agents, leanCtx: lock.leanCtx }, null, 2)}\n`;
|
|
96
|
+
if (existsSync(path) && readFileSync(path, "utf8") === next) return;
|
|
94
97
|
mkdirSync(dirname(path), { recursive: true });
|
|
95
|
-
writeFileSync(path,
|
|
98
|
+
writeFileSync(path, next);
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
/** True iff the lock records `serverName` as owned in `file`'s json-merge entry. */
|
|
@@ -36,6 +36,8 @@ export interface DeployMcpOptions {
|
|
|
36
36
|
readonly profile?: DeployProfile;
|
|
37
37
|
/** Defaults to loading `profile.mcp.source` relative to the package root. */
|
|
38
38
|
readonly servers?: readonly McpServerSpec[];
|
|
39
|
+
/** Computes the exact same written/unchanged/conflicts result without writing anything (independent review, 2026-09-29, finding 6). */
|
|
40
|
+
readonly dryRun?: boolean;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export interface McpFileConflict {
|
|
@@ -65,8 +67,8 @@ function readExistingJson(path: string): Record<string, unknown> | undefined {
|
|
|
65
67
|
return doc as Record<string, unknown>;
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
function writeIfChanged(path: string, content: string, changed: boolean): void {
|
|
69
|
-
if (!changed) return;
|
|
70
|
+
function writeIfChanged(path: string, content: string, changed: boolean, dryRun: boolean): void {
|
|
71
|
+
if (!changed || dryRun) return;
|
|
70
72
|
mkdirSync(dirname(path), { recursive: true });
|
|
71
73
|
writeFileSync(path, content);
|
|
72
74
|
}
|
|
@@ -77,6 +79,7 @@ function serializeJsonStyle(jsonStyle: "width-aware" | "expanded", value: Record
|
|
|
77
79
|
|
|
78
80
|
export function deployMcp(options: DeployMcpOptions): DeployMcpResult {
|
|
79
81
|
const { targetRepo } = options;
|
|
82
|
+
const dryRun = options.dryRun === true;
|
|
80
83
|
if (!existsSync(targetRepo) || !statSync(targetRepo).isDirectory()) {
|
|
81
84
|
throw new Error(`deploy target is not a directory: ${targetRepo}`);
|
|
82
85
|
}
|
|
@@ -118,7 +121,7 @@ export function deployMcp(options: DeployMcpOptions): DeployMcpResult {
|
|
|
118
121
|
|
|
119
122
|
const rendered = renderCodexManagedBlock(servers, marker, surface.tableKey as string, surface.entry, allowedTopLevel);
|
|
120
123
|
const { fileContent, changed } = mergeCodexBlock(existingText, rendered);
|
|
121
|
-
writeIfChanged(targetAbs, fileContent, changed);
|
|
124
|
+
writeIfChanged(targetAbs, fileContent, changed, dryRun);
|
|
122
125
|
(changed ? written : unchanged).push(surface.file);
|
|
123
126
|
newMcpEntries[surface.file] = { kind: "block", owned: true };
|
|
124
127
|
// The managed block is always regenerated (M3) regardless of prior
|
|
@@ -147,12 +150,12 @@ export function deployMcp(options: DeployMcpOptions): DeployMcpResult {
|
|
|
147
150
|
const { fileContent, ownedServers, conflicts: serverConflicts, changed } = mergeServerMap(existingRaw, topKey, desired, effectivePreviousOwned);
|
|
148
151
|
for (const c of serverConflicts) conflicts.push({ file: surface.file, server: c.server, reason: c.reason });
|
|
149
152
|
|
|
150
|
-
writeIfChanged(targetAbs, `${serializeJsonStyle(surface.jsonStyle as "width-aware" | "expanded", fileContent)}\n`, changed);
|
|
153
|
+
writeIfChanged(targetAbs, `${serializeJsonStyle(surface.jsonStyle as "width-aware" | "expanded", fileContent)}\n`, changed, dryRun);
|
|
151
154
|
(changed ? written : unchanged).push(surface.file);
|
|
152
155
|
newMcpEntries[surface.file] = { kind: "json", servers: ownedServers };
|
|
153
156
|
}
|
|
154
157
|
|
|
155
|
-
writeLock(targetRepo, { schema: 1, mcp: newMcpEntries, agents: lock.agents, leanCtx: lock.leanCtx });
|
|
158
|
+
if (!dryRun) writeLock(targetRepo, { schema: 1, mcp: newMcpEntries, agents: lock.agents, leanCtx: lock.leanCtx });
|
|
156
159
|
|
|
157
160
|
// Once anything was actually taken over, this package's own footprint in
|
|
158
161
|
// the legacy lock (the four MCP `merged` sections and the codex `toml`
|
|
@@ -160,7 +163,7 @@ export function deployMcp(options: DeployMcpOptions): DeployMcpResult {
|
|
|
160
163
|
// .agents/agent-settings.lock.json just written above. A legacy lock
|
|
161
164
|
// present but untouched by any takeover (no hash matched, or this repo
|
|
162
165
|
// never had one) is left exactly as found.
|
|
163
|
-
if (anyLegacyTakeover && legacyDoc !== undefined && legacyLockPath !== undefined) {
|
|
166
|
+
if (!dryRun && anyLegacyTakeover && legacyDoc !== undefined && legacyLockPath !== undefined) {
|
|
164
167
|
stripLegacyOwnership(legacyDoc, legacyLockPath, { mergedEntries: legacyMergedRemovals, toml: legacyTomlRemovals });
|
|
165
168
|
}
|
|
166
169
|
|