@udondan/avanti 0.23.1 → 0.25.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.
Files changed (100) hide show
  1. package/README.md +578 -65
  2. package/dist/commands/diff.d.ts.map +1 -1
  3. package/dist/commands/diff.js +82 -15
  4. package/dist/commands/diff.js.map +1 -1
  5. package/dist/commands/lock.d.ts.map +1 -1
  6. package/dist/commands/lock.js +3 -2
  7. package/dist/commands/lock.js.map +1 -1
  8. package/dist/commands/log.d.ts.map +1 -1
  9. package/dist/commands/log.js +7 -5
  10. package/dist/commands/log.js.map +1 -1
  11. package/dist/commands/pull.d.ts.map +1 -1
  12. package/dist/commands/pull.js +695 -32
  13. package/dist/commands/pull.js.map +1 -1
  14. package/dist/commands/reset.d.ts.map +1 -1
  15. package/dist/commands/reset.js +43 -11
  16. package/dist/commands/reset.js.map +1 -1
  17. package/dist/commands/revert.d.ts.map +1 -1
  18. package/dist/commands/revert.js +68 -14
  19. package/dist/commands/revert.js.map +1 -1
  20. package/dist/condition.d.ts.map +1 -1
  21. package/dist/condition.js +9 -6
  22. package/dist/condition.js.map +1 -1
  23. package/dist/config-writeback.d.ts.map +1 -1
  24. package/dist/config-writeback.js +33 -4
  25. package/dist/config-writeback.js.map +1 -1
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +396 -10
  28. package/dist/config.js.map +1 -1
  29. package/dist/diff.d.ts +25 -1
  30. package/dist/diff.d.ts.map +1 -1
  31. package/dist/diff.js +410 -28
  32. package/dist/diff.js.map +1 -1
  33. package/dist/extract.d.ts +4 -0
  34. package/dist/extract.d.ts.map +1 -0
  35. package/dist/extract.js +142 -0
  36. package/dist/extract.js.map +1 -0
  37. package/dist/filter.d.ts +3 -0
  38. package/dist/filter.d.ts.map +1 -0
  39. package/dist/filter.js +126 -0
  40. package/dist/filter.js.map +1 -0
  41. package/dist/history.d.ts +7 -1
  42. package/dist/history.d.ts.map +1 -1
  43. package/dist/history.js +89 -9
  44. package/dist/history.js.map +1 -1
  45. package/dist/paths.d.ts +4 -0
  46. package/dist/paths.d.ts.map +1 -1
  47. package/dist/paths.js +97 -0
  48. package/dist/paths.js.map +1 -1
  49. package/dist/processors/ini.d.ts +33 -0
  50. package/dist/processors/ini.d.ts.map +1 -0
  51. package/dist/processors/ini.js +500 -0
  52. package/dist/processors/ini.js.map +1 -0
  53. package/dist/processors/insert.d.ts.map +1 -1
  54. package/dist/processors/insert.js +338 -15
  55. package/dist/processors/insert.js.map +1 -1
  56. package/dist/processors/on.d.ts +4 -0
  57. package/dist/processors/on.d.ts.map +1 -0
  58. package/dist/processors/on.js +54 -0
  59. package/dist/processors/on.js.map +1 -0
  60. package/dist/ref.d.ts +21 -0
  61. package/dist/ref.d.ts.map +1 -0
  62. package/dist/ref.js +65 -0
  63. package/dist/ref.js.map +1 -0
  64. package/dist/sources/bitbucket.d.ts.map +1 -1
  65. package/dist/sources/bitbucket.js +51 -12
  66. package/dist/sources/bitbucket.js.map +1 -1
  67. package/dist/sources/git.d.ts.map +1 -1
  68. package/dist/sources/git.js +54 -6
  69. package/dist/sources/git.js.map +1 -1
  70. package/dist/sources/github.d.ts +1 -0
  71. package/dist/sources/github.d.ts.map +1 -1
  72. package/dist/sources/github.js +287 -50
  73. package/dist/sources/github.js.map +1 -1
  74. package/dist/sources/gitlab.d.ts +1 -0
  75. package/dist/sources/gitlab.d.ts.map +1 -1
  76. package/dist/sources/gitlab.js +387 -28
  77. package/dist/sources/gitlab.js.map +1 -1
  78. package/dist/sources/index.d.ts +3 -1
  79. package/dist/sources/index.d.ts.map +1 -1
  80. package/dist/sources/index.js +206 -44
  81. package/dist/sources/index.js.map +1 -1
  82. package/dist/sources/local.d.ts +3 -0
  83. package/dist/sources/local.d.ts.map +1 -1
  84. package/dist/sources/local.js +44 -0
  85. package/dist/sources/local.js.map +1 -1
  86. package/dist/types.d.ts +71 -6
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/types.js.map +1 -1
  89. package/dist/variables-remote.d.ts.map +1 -1
  90. package/dist/variables-remote.js +88 -7
  91. package/dist/variables-remote.js.map +1 -1
  92. package/dist/variables.d.ts +1 -0
  93. package/dist/variables.d.ts.map +1 -1
  94. package/dist/variables.js +133 -12
  95. package/dist/variables.js.map +1 -1
  96. package/dist/writer.d.ts +18 -0
  97. package/dist/writer.d.ts.map +1 -1
  98. package/dist/writer.js +968 -25
  99. package/dist/writer.js.map +1 -1
  100. package/package.json +12 -8
@@ -42,12 +42,13 @@ const condition_1 = require("../condition");
42
42
  const sources_1 = require("../sources");
43
43
  const dependencies_1 = require("../dependencies");
44
44
  const replace_1 = require("../processors/replace");
45
- const post_1 = require("../processors/post");
45
+ const on_1 = require("../processors/on");
46
46
  const insert_1 = require("../processors/insert");
47
47
  const binary_1 = require("../binary");
48
48
  const diff_1 = require("../diff");
49
49
  const writer_1 = require("../writer");
50
50
  const paths_1 = require("../paths");
51
+ const local_1 = require("../sources/local");
51
52
  const history_1 = require("../history");
52
53
  const prompt_1 = require("../prompt");
53
54
  const config_writeback_1 = require("../config-writeback");
@@ -63,6 +64,7 @@ async function runFetchLoop(config, workingDir, dateVars, cache, configPath, his
63
64
  return {
64
65
  writeTargets: [],
65
66
  allDiffs: [],
67
+ fileHookContexts: [],
66
68
  hasError: true,
67
69
  shaErrors: [],
68
70
  sourceRecordsByTarget: new Map(),
@@ -75,8 +77,10 @@ async function runFetchLoop(config, workingDir, dateVars, cache, configPath, his
75
77
  vars['self'] = configPath;
76
78
  }
77
79
  Object.assign(vars, dateVars);
80
+ Object.assign(vars, (0, variables_1.buildSystemVars)());
78
81
  const writeTargets = [];
79
82
  const allDiffs = [];
83
+ const fileHookContexts = [];
80
84
  const shaErrors = [];
81
85
  const seenShaErrorLabels = new Set();
82
86
  const sourceRecordsByTarget = new Map();
@@ -87,6 +91,7 @@ async function runFetchLoop(config, workingDir, dateVars, cache, configPath, his
87
91
  let hasError = false;
88
92
  let selfContent;
89
93
  let selfMode;
94
+ let selfSudo;
90
95
  let selfSourceRecords;
91
96
  let hasSelf = config_1.SELF_KEY in config.files;
92
97
  if (hasSelf) {
@@ -101,6 +106,7 @@ async function runFetchLoop(config, workingDir, dateVars, cache, configPath, his
101
106
  return {
102
107
  writeTargets,
103
108
  allDiffs,
109
+ fileHookContexts,
104
110
  hasError: true,
105
111
  shaErrors,
106
112
  sourceRecordsByTarget,
@@ -127,6 +133,7 @@ async function runFetchLoop(config, workingDir, dateVars, cache, configPath, his
127
133
  return {
128
134
  writeTargets: [],
129
135
  allDiffs: [],
136
+ fileHookContexts: [],
130
137
  hasError: true,
131
138
  shaErrors: [],
132
139
  sourceRecordsByTarget: new Map(),
@@ -146,23 +153,127 @@ async function runFetchLoop(config, workingDir, dateVars, cache, configPath, his
146
153
  const preVars = (0, paths_1.buildEntryPreVars)(entry, isSelf, workingDir, vars);
147
154
  if (!isSelf &&
148
155
  !(0, condition_1.evaluateConditions)(entry['if'], entry.ifAny, () => (0, paths_1.resolveTargetPath)(entry, '', workingDir, vars), workingDir, preVars)) {
156
+ let symlinkPath;
149
157
  try {
150
- skippedPaths.add((0, paths_1.resolveTargetPath)(entry, '', workingDir, vars));
158
+ symlinkPath = (0, paths_1.resolveTargetPath)(entry, '', workingDir, vars);
151
159
  }
152
160
  catch {
153
161
  console.warn(`Warning: skipped entry has an unresolvable target path — stale cleanup disabled for this run.`);
154
162
  hasUnresolvableSkippedPath = true;
163
+ continue;
164
+ }
165
+ skippedPaths.add(symlinkPath);
166
+ // Also skip the resolved real path so stale cleanup doesn't treat
167
+ // the symlink target as unmanaged when followSymlink is in use.
168
+ // Skip for directory targets — resolveFollowSymlink throws on dir symlinks.
169
+ // resolveFollowSymlink security errors (escape/directory/cycle) are
170
+ // intentionally NOT caught here — they propagate as hard failures.
171
+ const resolvedTarget0 = entry.target
172
+ ? (0, variables_1.resolveVars)(entry.target, vars)
173
+ : '';
174
+ if (!resolvedTarget0.endsWith('/') &&
175
+ !resolvedTarget0.endsWith(path.sep)) {
176
+ const realPath = (0, paths_1.resolveFollowSymlink)(symlinkPath, entry, workingDir);
177
+ if (realPath !== symlinkPath)
178
+ skippedPaths.add(realPath);
179
+ }
180
+ continue;
181
+ }
182
+ // Symlink entries: resolve src path and create a symlink instead of
183
+ // fetching and writing file content.
184
+ if (!isSelf && entry.symlink) {
185
+ if (process.platform === 'win32') {
186
+ console.error(`Error processing ${JSON.stringify(entry.src)}: symlink entries are not supported on Windows; use an \`if: { os: [linux, mac] }\` condition to gate symlink entries in cross-platform configs`);
187
+ hasError = true;
188
+ continue;
189
+ }
190
+ const targetPath = (0, paths_1.resolveTargetPath)(entry, '', workingDir, vars);
191
+ if (Array.isArray(entry.src)) {
192
+ throw new Error(`files["${entry.target}"].symlink: src must be a single local path, not an array`);
193
+ }
194
+ const rawSrc = typeof entry.src === 'string'
195
+ ? entry.src
196
+ : entry.src.path;
197
+ // Honor optional: true — skip when the local source does not exist.
198
+ const isOptionalSrc = !Array.isArray(entry.src) &&
199
+ typeof entry.src !== 'string' &&
200
+ !!entry.src.optional;
201
+ if (isOptionalSrc) {
202
+ const absSrc = (0, local_1.resolveSymlinkSrcPath)(rawSrc, workingDir, preVars, true, targetPath);
203
+ if (!fs.existsSync(absSrc)) {
204
+ // Mark the target as skipped so stale cleanup does not treat a
205
+ // previously-managed path as unmanaged and delete/restore it.
206
+ skippedPaths.add(targetPath);
207
+ continue;
208
+ }
209
+ }
210
+ const symlinkTarget = (0, local_1.resolveSymlinkSrcPath)(rawSrc, workingDir, preVars, entry.symlink, targetPath);
211
+ const diff = (0, diff_1.computeSymlinkDiff)(targetPath, symlinkTarget);
212
+ // Symlinks cannot replace existing directories — error early so the
213
+ // write batch is not attempted and EISDIR is not thrown at rename time.
214
+ // Do not push to allDiffs here: allDiffs and writeTargets are parallel
215
+ // arrays; pushing diff without a matching writeTargets entry would
216
+ // misalign subsequent index-based lookups.
217
+ if (diff.isDirectory) {
218
+ console.error(`Error processing ${JSON.stringify(entry.src)}: symlink: ${targetPath} is a directory; cannot replace with a symlink`);
219
+ hasError = true;
220
+ continue;
221
+ }
222
+ allDiffs.push(diff);
223
+ const symlinkContent = Buffer.from(symlinkTarget, 'utf8');
224
+ const symlinkBackupPath = entry.backup && diff.hasChanges && !diff.isNew
225
+ ? (0, variables_1.resolveBackupPath)(entry.backup, targetPath, workingDir, vars, config.backup_roots ?? [])
226
+ : undefined;
227
+ writeTargets.push({
228
+ targetPath,
229
+ content: symlinkContent,
230
+ symlinkTarget,
231
+ backupPath: symlinkBackupPath,
232
+ sudo: entry.sudo,
233
+ });
234
+ if (entry.on && diff.hasChanges) {
235
+ fileHookContexts.push({
236
+ targetPath,
237
+ hooks: entry.on,
238
+ isNew: diff.isNew,
239
+ });
240
+ }
241
+ // Register the resolved src content (not the symlink target string) in
242
+ // pendingWrites so subsequent local entries that read through this symlink
243
+ // path see the actual file bytes, not the raw symlink target path.
244
+ const absSymlinkSrc = (0, local_1.resolveSymlinkSrcPath)(rawSrc, workingDir, preVars, true, targetPath);
245
+ try {
246
+ const srcStat = fs.statSync(absSymlinkSrc, { throwIfNoEntry: false });
247
+ if (srcStat?.isFile()) {
248
+ pendingWrites.set(targetPath, fs.readFileSync(absSymlinkSrc));
249
+ }
250
+ }
251
+ catch {
252
+ // src not readable — omit from pendingWrites
155
253
  }
156
254
  continue;
157
255
  }
158
256
  const result = await (0, sources_1.fetchSource)(entry, workingDir, preVars, cache, isSelf && configPath !== undefined ? () => configPath : undefined, pendingWrites);
159
257
  if (result.allSkipped && !isSelf) {
258
+ let symlinkPath2;
160
259
  try {
161
- skippedPaths.add((0, paths_1.resolveTargetPath)(entry, '', workingDir, vars));
260
+ symlinkPath2 = (0, paths_1.resolveTargetPath)(entry, '', workingDir, vars);
162
261
  }
163
262
  catch {
164
263
  console.warn(`Warning: skipped entry has an unresolvable target path — stale cleanup disabled for this run.`);
165
264
  hasUnresolvableSkippedPath = true;
265
+ continue;
266
+ }
267
+ skippedPaths.add(symlinkPath2);
268
+ // resolveFollowSymlink security errors propagate as hard failures.
269
+ const resolvedTarget2 = entry.target
270
+ ? (0, variables_1.resolveVars)(entry.target, vars)
271
+ : '';
272
+ if (!resolvedTarget2.endsWith('/') &&
273
+ !resolvedTarget2.endsWith(path.sep)) {
274
+ const realPath = (0, paths_1.resolveFollowSymlink)(symlinkPath2, entry, workingDir);
275
+ if (realPath !== symlinkPath2)
276
+ skippedPaths.add(realPath);
166
277
  }
167
278
  continue;
168
279
  }
@@ -193,6 +304,11 @@ async function runFetchLoop(config, workingDir, dateVars, cache, configPath, his
193
304
  const entryVars = targetPath !== undefined
194
305
  ? Object.assign(Object.create(null), vars, (0, variables_1.buildFileVars)(targetPath))
195
306
  : vars;
307
+ // Resolve any symlink on the target path early so insert-mode tracking
308
+ // and all subsequent operations use the real file path consistently.
309
+ const effectivePath = targetPath !== undefined
310
+ ? (0, paths_1.resolveFollowSymlink)(targetPath, entry, workingDir)
311
+ : undefined;
196
312
  let content = rawContent;
197
313
  if (!(0, binary_1.isBinary)(content)) {
198
314
  // Processors only operate on text; binary files are passed through unchanged.
@@ -203,20 +319,22 @@ async function runFetchLoop(config, workingDir, dateVars, cache, configPath, his
203
319
  }
204
320
  if (entry.replace?.length)
205
321
  text = (0, replace_1.applyReplace)(text, entry.replace, entryVars);
206
- if (entry.post)
207
- text = (0, post_1.applyPost)(text, entry.post, entryVars);
322
+ if (entry.on?.write)
323
+ text = (0, on_1.applyWriteHook)(text, entry.on.write, entryVars);
208
324
  if (entry.strategy === 'insert' && !isSelf) {
209
- const lastInserted = history?.getInsertedFragment(targetPath) ?? null;
325
+ const lastInserted = history?.getInsertedFragment(effectivePath) ?? null;
210
326
  if (lastInserted !== null &&
211
327
  rawText === lastInserted.raw &&
212
328
  text === lastInserted.processed &&
213
- fs.existsSync(targetPath)) {
329
+ fs.existsSync(effectivePath)) {
214
330
  skippedPaths.add(targetPath); // keep stale detection from treating this as missing
331
+ if (effectivePath !== targetPath)
332
+ skippedPaths.add(effectivePath);
215
333
  continue; // source and processed output unchanged — skip write entirely (no-op)
216
334
  }
217
335
  const processedText = text;
218
- text = (0, insert_1.applyInsertMode)(entry, processedText, lastInserted?.processed ?? null, targetPath);
219
- insertedFragments.set(targetPath, {
336
+ text = (0, insert_1.applyInsertMode)(entry, processedText, lastInserted?.processed ?? null, effectivePath);
337
+ insertedFragments.set(effectivePath, {
220
338
  raw: rawText,
221
339
  processed: processedText,
222
340
  });
@@ -226,24 +344,47 @@ async function runFetchLoop(config, workingDir, dateVars, cache, configPath, his
226
344
  if (isSelf) {
227
345
  selfContent = content.toString('utf8');
228
346
  selfMode = entry.mode;
347
+ selfSudo = entry.sudo || undefined;
229
348
  if (result.sourceRecords.length > 0)
230
349
  selfSourceRecords = result.sourceRecords;
231
350
  continue;
232
351
  }
233
- const diff = (0, diff_1.computeDiff)(targetPath, content);
352
+ // effectivePath is always defined here: isSelf is false (we continued above),
353
+ // so targetPath was defined, and effectivePath = resolveFollowSymlink(targetPath, ...).
354
+ const ep = effectivePath;
355
+ const diff = (0, diff_1.computeDiff)(ep, content, entry.mode);
234
356
  allDiffs.push(diff);
235
357
  const backupPath = entry.backup && diff.hasChanges && !diff.isNew
236
- ? (0, variables_1.resolveBackupPath)(entry.backup, targetPath, workingDir, vars, config.backup_roots ?? [])
358
+ ? (0, variables_1.resolveBackupPath)(entry.backup, ep, workingDir, vars, config.backup_roots ?? [])
237
359
  : undefined;
238
360
  writeTargets.push({
239
- targetPath: targetPath,
361
+ targetPath: ep,
240
362
  content,
241
363
  mode: entry.mode,
242
364
  backupPath,
365
+ writeInPlace: entry.writeInPlace,
366
+ sudo: entry.sudo,
243
367
  });
244
- pendingWrites.set(targetPath, content);
368
+ if (entry.on && diff.hasChanges) {
369
+ fileHookContexts.push({
370
+ targetPath: ep,
371
+ hooks: entry.on,
372
+ isNew: diff.isNew,
373
+ });
374
+ }
375
+ pendingWrites.set(ep, content);
376
+ // Also index under the original symlink path so local source lookups
377
+ // using the symlink path (not the resolved real path) still find the
378
+ // pending content within the same fetch loop.
379
+ if (ep !== targetPath) {
380
+ pendingWrites.set(targetPath, content);
381
+ // Mark the symlink path as covered so stale detection doesn't treat
382
+ // a previously-tracked symlink path as stale when followSymlink is
383
+ // enabled on an entry that was first pulled without it.
384
+ skippedPaths.add(targetPath);
385
+ }
245
386
  if (result.sourceRecords.length > 0) {
246
- sourceRecordsByTarget.set(targetPath, result.sourceRecords);
387
+ sourceRecordsByTarget.set(ep, result.sourceRecords);
247
388
  }
248
389
  }
249
390
  }
@@ -255,6 +396,7 @@ async function runFetchLoop(config, workingDir, dateVars, cache, configPath, his
255
396
  return {
256
397
  writeTargets,
257
398
  allDiffs,
399
+ fileHookContexts,
258
400
  hasError,
259
401
  shaErrors,
260
402
  sourceRecordsByTarget,
@@ -263,12 +405,13 @@ async function runFetchLoop(config, workingDir, dateVars, cache, configPath, his
263
405
  insertedFragments,
264
406
  selfContent,
265
407
  selfMode,
408
+ selfSudo,
266
409
  selfSourceRecords,
267
410
  };
268
411
  }
269
412
  function printShaErrors(errors) {
270
413
  for (const e of errors) {
271
- console.error(`SHA mismatch for ${e.sourceLabel}\n` +
414
+ console.error(`SHA mismatch for ${(0, sources_1.formatSourceLabel)(e.sourceLabel)}\n` +
272
415
  ` expected: ${e.expectedSha}\n` +
273
416
  ` got: ${e.observedSha}`);
274
417
  }
@@ -283,7 +426,7 @@ function pullCommand() {
283
426
  const configPath = (0, config_1.resolveConfigPath)(cmd.parent?.opts().config);
284
427
  const rawWorkingDir = cmd.parent?.opts().workingDir;
285
428
  const workingDir = rawWorkingDir
286
- ? path.resolve(rawWorkingDir)
429
+ ? path.resolve((0, paths_1.expandTilde)(rawWorkingDir))
287
430
  : process.cwd();
288
431
  const via = (0, config_1.parseVia)(cmd.parent?.opts().via, '--via');
289
432
  let config;
@@ -301,6 +444,7 @@ function pullCommand() {
301
444
  const dateVars = (0, variables_1.buildDateVars)();
302
445
  const firstPass = await runFetchLoop(config, workingDir, dateVars, fetchCache, configPath, history);
303
446
  let { writeTargets, allDiffs, sourceRecordsByTarget } = firstPass;
447
+ let fileHookContexts = firstPass.fileHookContexts;
304
448
  let insertedFragments = firstPass.insertedFragments;
305
449
  let skippedPaths = firstPass.skippedPaths;
306
450
  let hasUnresolvableSkippedPath = firstPass.hasUnresolvableSkippedPath;
@@ -320,6 +464,7 @@ function pullCommand() {
320
464
  let prevSelfContent;
321
465
  let currentSelfContent = firstPass.selfContent;
322
466
  let currentSelfMode = firstPass.selfMode;
467
+ let currentSelfSudo = firstPass.selfSudo;
323
468
  let currentSelfSourceRecords = firstPass.selfSourceRecords;
324
469
  let stableConfig;
325
470
  while (stableConfig === undefined) {
@@ -361,6 +506,7 @@ function pullCommand() {
361
506
  prevSelfContent = currentSelfContent;
362
507
  currentSelfContent = next.selfContent;
363
508
  currentSelfMode = next.selfMode;
509
+ currentSelfSudo = next.selfSudo;
364
510
  currentSelfSourceRecords = next.selfSourceRecords;
365
511
  }
366
512
  if (stableConfig !== undefined) {
@@ -385,6 +531,7 @@ function pullCommand() {
385
531
  }
386
532
  writeTargets = second.writeTargets;
387
533
  allDiffs = second.allDiffs;
534
+ fileHookContexts = second.fileHookContexts;
388
535
  sourceRecordsByTarget = second.sourceRecordsByTarget;
389
536
  insertedFragments = second.insertedFragments;
390
537
  skippedPaths = second.skippedPaths;
@@ -408,16 +555,22 @@ function pullCommand() {
408
555
  targetPath: configPath,
409
556
  content: selfBuf,
410
557
  mode: currentSelfMode,
558
+ sudo: currentSelfSudo,
411
559
  });
412
- allDiffs.push((0, diff_1.computeDiff)(configPath, selfBuf));
560
+ allDiffs.push((0, diff_1.computeDiff)(configPath, selfBuf, currentSelfMode));
413
561
  }
414
562
  else {
563
+ const resolvedSelfMode = currentSelfMode ?? writeTargets[existingIdx].mode;
415
564
  writeTargets[existingIdx] = {
416
565
  ...writeTargets[existingIdx],
417
566
  content: selfBuf,
418
- mode: currentSelfMode ?? writeTargets[existingIdx].mode,
567
+ mode: resolvedSelfMode,
568
+ // Fall back to the existing target's sudo identity when $self
569
+ // doesn't specify one, so a privileged config file keeps its
570
+ // write privileges after $self stabilization.
571
+ sudo: currentSelfSudo ?? writeTargets[existingIdx].sudo,
419
572
  };
420
- allDiffs[existingIdx] = (0, diff_1.computeDiff)(configPath, selfBuf);
573
+ allDiffs[existingIdx] = (0, diff_1.computeDiff)(configPath, selfBuf, resolvedSelfMode);
421
574
  }
422
575
  // Content comes from $self — attribute the config file write to the
423
576
  // $self sources so history reflects the actual origin.
@@ -432,8 +585,15 @@ function pullCommand() {
432
585
  }
433
586
  // Detect stale files: present in last pull but no longer in current source fetch
434
587
  const staleToDelete = [];
588
+ // Maps path → sudo identity for stale files that need privileged deletion
589
+ const staleDeleteSudo = new Map();
590
+ // Maps path → staleDiffs index so we can check hasChanges before auth/write
591
+ const staleDeleteDiffIndex = new Map();
435
592
  const staleToRestore = [];
436
593
  const staleDiffs = [];
594
+ // Parallel array: staleDiffs index for each staleToRestore entry
595
+ const staleRestoreDiffIndices = [];
596
+ let staleHasError = false;
437
597
  if (historyAvailable && !hasUnresolvableSkippedPath) {
438
598
  const lastFiles = history.getLastPullFiles();
439
599
  const currentPaths = new Set(writeTargets.map((t) => t.targetPath));
@@ -455,22 +615,253 @@ function pullCommand() {
455
615
  if (meta.existedBeforeAvanti) {
456
616
  const original = history.readVersion(ref.absolutePath, 0);
457
617
  if (original !== null) {
458
- staleToRestore.push({
459
- targetPath: ref.absolutePath,
460
- content: original,
461
- });
462
- staleDiffs.push((0, diff_1.computeDiff)(ref.absolutePath, original));
618
+ if (meta.v0IsSymlink) {
619
+ if (process.platform === 'win32') {
620
+ console.error(`symlink: ${ref.absolutePath}: cannot restore pre-avanti symlink on Windows`);
621
+ staleHasError = true;
622
+ // Show the actual stored target so the user knows what cannot
623
+ // be restored. No staleRestoreDiffIndices push — there is no
624
+ // corresponding staleToRestore entry for error-only diffs.
625
+ const symlinkTarget = original.toString('utf8');
626
+ const warnDiff = (0, diff_1.buildNewSymlinkDiff)(ref.absolutePath, symlinkTarget);
627
+ staleDiffs.push({ ...warnDiff, hasChanges: true });
628
+ }
629
+ else {
630
+ const symlinkTarget = original.toString('utf8');
631
+ const staleDiff = (0, diff_1.computeSymlinkDiff)(ref.absolutePath, symlinkTarget);
632
+ if (staleDiff.isDirectory) {
633
+ console.error(`symlink: ${ref.absolutePath} is a directory; cannot restore symlink over directory`);
634
+ staleHasError = true;
635
+ // No staleRestoreDiffIndices push — no corresponding
636
+ // staleToRestore entry for this error-only diff.
637
+ staleDiffs.push(staleDiff);
638
+ }
639
+ else if (staleDiff.hasChanges) {
640
+ staleToRestore.push({
641
+ targetPath: ref.absolutePath,
642
+ content: original,
643
+ symlinkTarget,
644
+ sudo: meta.sudo,
645
+ });
646
+ staleRestoreDiffIndices.push(staleDiffs.length);
647
+ staleDiffs.push(staleDiff);
648
+ }
649
+ }
650
+ }
651
+ else {
652
+ staleToRestore.push({
653
+ targetPath: ref.absolutePath,
654
+ content: original,
655
+ sudo: meta.sudo,
656
+ });
657
+ staleRestoreDiffIndices.push(staleDiffs.length);
658
+ const staleDiff = (0, diff_1.computeDiff)(ref.absolutePath, original);
659
+ // A missing file with empty v0 produces isNew=true but
660
+ // hasChanges=false and an empty patch, so formatDiff returns ''.
661
+ // Rebuild as a proper new-file diff so the confirmation output
662
+ // shows the recreate action and the patch is consistent.
663
+ staleDiffs.push(staleDiff.isNew
664
+ ? (0, diff_1.buildNewFileDiff)(ref.absolutePath, original)
665
+ : staleDiff);
666
+ }
667
+ }
668
+ else {
669
+ console.warn(`Warning: cannot restore original for ${ref.absolutePath} — v0 was never captured (file was unreadable at first pull). Leaving file unchanged.`);
463
670
  }
464
671
  }
465
672
  else {
466
673
  staleToDelete.push(ref.absolutePath);
674
+ if (meta.sudo)
675
+ staleDeleteSudo.set(ref.absolutePath, meta.sudo);
676
+ staleDeleteDiffIndex.set(ref.absolutePath, staleDiffs.length);
467
677
  staleDiffs.push((0, diff_1.computeDeleteDiff)(ref.absolutePath));
468
678
  }
469
679
  }
470
680
  }
681
+ // Windows does not have sudo; fail before any authentication attempt.
682
+ if (process.platform === 'win32' &&
683
+ (writeTargets.some((t) => t.sudo) ||
684
+ staleToRestore.some((t) => t.sudo) ||
685
+ staleDeleteSudo.size > 0)) {
686
+ console.error('sudo is not supported on Windows');
687
+ process.exit(2);
688
+ }
689
+ // Authenticate early for unreadable sudo files so we can read their actual
690
+ // content before deciding whether anything changed. Without this, every pull
691
+ // would show unreadable files as "changed" (we can't diff without reading),
692
+ // and "Nothing to do." could never be reported on a re-run.
693
+ const unreadableSudoValues = new Set();
694
+ for (let i = 0; i < writeTargets.length; i++) {
695
+ if (allDiffs[i].isUnreadable && writeTargets[i].sudo) {
696
+ unreadableSudoValues.add(writeTargets[i].sudo);
697
+ }
698
+ }
699
+ // Also auth for unreadable stale restore targets that need sudo.
700
+ for (let i = 0; i < staleToRestore.length; i++) {
701
+ const diffIdx = staleRestoreDiffIndices[i];
702
+ if (staleDiffs[diffIdx]?.isUnreadable && staleToRestore[i].sudo) {
703
+ unreadableSudoValues.add(staleToRestore[i].sudo);
704
+ }
705
+ }
706
+ const authenticatedSudoIds = new Set();
707
+ for (const sv of unreadableSudoValues) {
708
+ try {
709
+ (0, writer_1.sudoAuth)(sv);
710
+ }
711
+ catch (err) {
712
+ console.error(err instanceof Error ? err.message : String(err));
713
+ process.exit(2);
714
+ }
715
+ authenticatedSudoIds.add(sv);
716
+ }
717
+ // For entries where lstatSync failed (parent directory not searchable),
718
+ // use sudoFileExists to determine whether the file actually exists so
719
+ // existedBeforeAvanti is recorded correctly. Also compute modeChange now
720
+ // that we have sudo access (computeDiff could not stat the file pre-auth).
721
+ for (let i = 0; i < writeTargets.length; i++) {
722
+ if (allDiffs[i].lstatFailed && writeTargets[i].sudo) {
723
+ const exists = (0, writer_1.sudoFileExists)(writeTargets[i].sudo, writeTargets[i].targetPath);
724
+ const isNew = !exists;
725
+ let modeChange = allDiffs[i].modeChange;
726
+ if (exists && writeTargets[i].mode) {
727
+ const curModeStr = (0, writer_1.getSudoFileMode)(writeTargets[i].sudo, writeTargets[i].targetPath);
728
+ if (curModeStr !== undefined) {
729
+ const desired = parseInt(writeTargets[i].mode, 8);
730
+ const cur = parseInt(curModeStr, 8);
731
+ if (!isNaN(desired) && !isNaN(cur) && desired !== cur) {
732
+ modeChange = { from: cur, to: desired };
733
+ }
734
+ }
735
+ }
736
+ if (isNew) {
737
+ // File confirmed absent — rebuild as a proper new diff so
738
+ // formatDiff shows the actual content instead of "unreadable".
739
+ // Clear backupPath: it was set assuming the file existed (conservative
740
+ // lstatFailed default). Since the file is actually new, there is
741
+ // nothing to back up and the backup should not be created.
742
+ if (writeTargets[i].symlinkTarget !== undefined) {
743
+ // Symlink entry — use buildNewSymlinkDiff instead of
744
+ // computeSymlinkDiff: the parent dir is still not searchable
745
+ // (EACCES), so calling computeSymlinkDiff would lstatSync-fail
746
+ // again and return isUnreadable rather than isNew:true.
747
+ allDiffs[i] = (0, diff_1.buildNewSymlinkDiff)(allDiffs[i].targetPath, writeTargets[i].symlinkTarget);
748
+ }
749
+ else {
750
+ allDiffs[i] = (0, diff_1.buildNewFileDiff)(allDiffs[i].targetPath, writeTargets[i].content, modeChange);
751
+ }
752
+ writeTargets[i] = { ...writeTargets[i], backupPath: undefined };
753
+ }
754
+ else {
755
+ let updatedDiff = { ...allDiffs[i], isNew, modeChange };
756
+ // For symlink entries, check whether the existing path is a real
757
+ // directory — ln -sf would place the symlink inside it rather than
758
+ // replacing it, so detect this now and surface an error before the
759
+ // write batch is attempted.
760
+ if (writeTargets[i].symlinkTarget !== undefined) {
761
+ const isSymlinkAtTarget = (0, writer_1.sudoIsSymlink)(writeTargets[i].sudo, writeTargets[i].targetPath);
762
+ if (!isSymlinkAtTarget &&
763
+ (0, writer_1.sudoIsDirectory)(writeTargets[i].sudo, writeTargets[i].targetPath)) {
764
+ updatedDiff = { ...updatedDiff, isDirectory: true };
765
+ }
766
+ }
767
+ allDiffs[i] = updatedDiff;
768
+ }
769
+ // Propagate corrected isNew to the hook context so lifecycle hooks
770
+ // receive the correct AVANTI_IS_NEW value.
771
+ const hookIdx = fileHookContexts.findIndex((ctx) => ctx.targetPath === writeTargets[i].targetPath);
772
+ if (hookIdx >= 0) {
773
+ fileHookContexts[hookIdx] = { ...fileHookContexts[hookIdx], isNew };
774
+ }
775
+ }
776
+ }
777
+ // Fail fast if any symlink write target is a real directory: ln -sf would
778
+ // place the symlink inside it rather than replacing it, so abort before
779
+ // prompting the user rather than failing mid-write-batch.
780
+ for (const d of allDiffs) {
781
+ if (d.isDirectory && d.isSymlink) {
782
+ console.error(`symlink: ${d.targetPath} is a directory; cannot replace with a symlink`);
783
+ }
784
+ }
785
+ if (allDiffs.some((d) => d.isDirectory && d.isSymlink)) {
786
+ process.exit(2);
787
+ }
788
+ // Post-auth idempotency: compare current file content via sudo against the
789
+ // desired content. If they match, suppress the write for this entry.
790
+ for (let i = 0; i < writeTargets.length; i++) {
791
+ if (allDiffs[i].isUnreadable && writeTargets[i].sudo) {
792
+ if (writeTargets[i].symlinkTarget !== undefined) {
793
+ // Symlink entry: use sudo readlink to check whether the on-disk
794
+ // symlink already points to the desired target. If it does, the
795
+ // write is a no-op and the diff should be suppressed.
796
+ const current = (0, writer_1.sudoReadlink)(writeTargets[i].sudo, writeTargets[i].targetPath);
797
+ if (current !== null && current === writeTargets[i].symlinkTarget) {
798
+ const updatedHasChanges = allDiffs[i].modeChange !== undefined;
799
+ allDiffs[i] = {
800
+ ...allDiffs[i],
801
+ contentChanged: false,
802
+ hasChanges: updatedHasChanges,
803
+ };
804
+ if (!updatedHasChanges) {
805
+ const hookIdx = fileHookContexts.findIndex((ctx) => ctx.targetPath === writeTargets[i].targetPath);
806
+ if (hookIdx >= 0) {
807
+ fileHookContexts.splice(hookIdx, 1);
808
+ }
809
+ }
810
+ }
811
+ continue;
812
+ }
813
+ // For regular file entries: skip idempotency read when the existing
814
+ // path is a symlink — sudoRead follows symlinks and could compare
815
+ // against the wrong content (the symlink target's bytes, not the
816
+ // symlink itself).
817
+ if ((0, writer_1.sudoIsSymlink)(writeTargets[i].sudo, writeTargets[i].targetPath)) {
818
+ continue;
819
+ }
820
+ const current = (0, writer_1.sudoRead)(writeTargets[i].sudo, writeTargets[i].targetPath);
821
+ if (current !== null && current.equals(writeTargets[i].content)) {
822
+ const updatedHasChanges = allDiffs[i].modeChange !== undefined;
823
+ allDiffs[i] = {
824
+ ...allDiffs[i],
825
+ contentChanged: false,
826
+ hasChanges: updatedHasChanges,
827
+ };
828
+ // Only remove hook context when the diff is a true no-op (no mode
829
+ // change either). A mode-only change still triggers before/afterUpdate
830
+ // hooks, so the context must remain for those cases.
831
+ if (!updatedHasChanges) {
832
+ const hookIdx = fileHookContexts.findIndex((ctx) => ctx.targetPath === writeTargets[i].targetPath);
833
+ if (hookIdx >= 0) {
834
+ fileHookContexts.splice(hookIdx, 1);
835
+ }
836
+ }
837
+ }
838
+ }
839
+ }
840
+ // Same idempotency check for stale restore targets: if the current file
841
+ // content already matches the v0 original, suppress the redundant write.
842
+ for (let i = 0; i < staleToRestore.length; i++) {
843
+ const diffIdx = staleRestoreDiffIndices[i];
844
+ if (staleDiffs[diffIdx]?.isUnreadable && staleToRestore[i].sudo) {
845
+ // Skip idempotency read for symlinks: sudoRead follows symlinks and
846
+ // could read an unintended privileged file before write-path checks run.
847
+ if ((0, writer_1.sudoIsSymlink)(staleToRestore[i].sudo, staleToRestore[i].targetPath)) {
848
+ continue;
849
+ }
850
+ const current = (0, writer_1.sudoRead)(staleToRestore[i].sudo, staleToRestore[i].targetPath);
851
+ if (current !== null && current.equals(staleToRestore[i].content)) {
852
+ staleDiffs[diffIdx] = {
853
+ ...staleDiffs[diffIdx],
854
+ contentChanged: false,
855
+ hasChanges: staleDiffs[diffIdx].modeChange !== undefined,
856
+ };
857
+ }
858
+ }
859
+ }
471
860
  const hasChanges = allDiffs.some((d) => d.hasChanges) ||
472
861
  staleDiffs.some((d) => d.hasChanges);
473
862
  (0, diff_1.printDiffs)([...allDiffs, ...staleDiffs]);
863
+ if (staleHasError)
864
+ process.exit(2);
474
865
  // Show SHA mismatch summary when using --accept-changes
475
866
  if (opts.acceptChanges && firstPass.shaErrors.length > 0) {
476
867
  console.error('');
@@ -482,6 +873,26 @@ function pullCommand() {
482
873
  history.saveInsertedFragment(targetPath, fragment.raw, fragment.processed);
483
874
  }
484
875
  }
876
+ // Prune no-op stale refs from the pull log. When all diffs are clean,
877
+ // every stale entry (delete or restore) was already resolved outside of
878
+ // avanti (file manually deleted, or content already matches v0). Without
879
+ // this, those refs remain in the last-pull log and can incorrectly flag
880
+ // a future file at the same path as stale.
881
+ if (pullId &&
882
+ historyAvailable &&
883
+ (staleToDelete.length > 0 || staleToRestore.length > 0)) {
884
+ const noopStalePaths = new Set([
885
+ ...staleToDelete,
886
+ ...staleToRestore.map((t) => t.targetPath),
887
+ ]);
888
+ const lastFiles = history.getLastPullFiles();
889
+ const survivingRefs = lastFiles.filter((ref) => !noopStalePaths.has(ref.absolutePath));
890
+ // Preserve existing sudo on surviving refs — no file was written, so
891
+ // the on-disk ownership reflects the previous write identity, not the
892
+ // current config. Overwriting here would let stale cleanup run with
893
+ // the wrong privileges for a file it never re-wrote.
894
+ history.closePullSession(pullId, (0, config_1.normalizeConfigKey)(configPath), survivingRefs);
895
+ }
485
896
  console.log('Nothing to do.');
486
897
  process.exit(0);
487
898
  }
@@ -529,6 +940,74 @@ function pullCommand() {
529
940
  }
530
941
  }
531
942
  }
943
+ for (const ctx of fileHookContexts) {
944
+ const env = {
945
+ AVANTI_TARGET: ctx.targetPath,
946
+ AVANTI_IS_NEW: String(ctx.isNew),
947
+ };
948
+ const runNamedHook = (key, script) => {
949
+ try {
950
+ (0, on_1.runHook)(script, env);
951
+ }
952
+ catch (err) {
953
+ console.error(`Hook ${key} failed for ${ctx.targetPath}: ${err instanceof Error ? err.message : String(err)}`);
954
+ process.exit(2);
955
+ }
956
+ };
957
+ if (ctx.hooks.beforeWrite)
958
+ runNamedHook('beforeWrite', ctx.hooks.beforeWrite);
959
+ if (ctx.isNew && ctx.hooks.beforeCreate)
960
+ runNamedHook('beforeCreate', ctx.hooks.beforeCreate);
961
+ if (!ctx.isNew && ctx.hooks.beforeUpdate)
962
+ runNamedHook('beforeUpdate', ctx.hooks.beforeUpdate);
963
+ }
964
+ // Compute write batches and authenticate before staging so that sudo is
965
+ // available for v0 capture of unreadable first-seen files (history must
966
+ // record the original content before it is overwritten).
967
+ const changedTargets = writeTargets.filter((_, i) => allDiffs[i].hasChanges && allDiffs[i].contentChanged);
968
+ // Only include stale restore targets whose diff still has changes (not
969
+ // suppressed by the idempotency check above). Also include diffs where
970
+ // isNew is true: a missing file with empty v0 produces hasChanges=false
971
+ // ('' !== '' = false) but still needs to be written.
972
+ const activeStaleRestoreIndices = staleToRestore
973
+ .map((_, i) => i)
974
+ .filter((i) => {
975
+ const d = staleDiffs[staleRestoreDiffIndices[i]];
976
+ return d?.hasChanges || d?.isNew;
977
+ });
978
+ const activeStaleRestore = activeStaleRestoreIndices.map((i) => staleToRestore[i]);
979
+ const sudoValues = new Set([...changedTargets, ...activeStaleRestore]
980
+ .map((t) => t.sudo)
981
+ .filter(Boolean));
982
+ // Only include stale delete sudo identities when the delete diff still
983
+ // has changes (file wasn't already absent at diff time).
984
+ for (const [p, sv] of staleDeleteSudo) {
985
+ const idx = staleDeleteDiffIndex.get(p);
986
+ if (idx !== undefined && staleDiffs[idx].hasChanges) {
987
+ sudoValues.add(sv);
988
+ }
989
+ }
990
+ for (let i = 0; i < writeTargets.length; i++) {
991
+ if (allDiffs[i].modeChange &&
992
+ !allDiffs[i].contentChanged &&
993
+ writeTargets[i].sudo) {
994
+ sudoValues.add(writeTargets[i].sudo);
995
+ }
996
+ }
997
+ // Skip identities already authenticated in the early unreadable-file pass
998
+ // so a single pull session never re-prompts for the same identity.
999
+ for (const sv of sudoValues) {
1000
+ if (!authenticatedSudoIds.has(sv)) {
1001
+ try {
1002
+ (0, writer_1.sudoAuth)(sv);
1003
+ }
1004
+ catch (err) {
1005
+ console.error(err instanceof Error ? err.message : String(err));
1006
+ process.exit(2);
1007
+ }
1008
+ authenticatedSudoIds.add(sv);
1009
+ }
1010
+ }
532
1011
  // Stage history versions before atomicWrite so v0 is captured before overwrite
533
1012
  const stagedFileRefs = [];
534
1013
  if (pullId) {
@@ -551,7 +1030,36 @@ function pullCommand() {
551
1030
  acceptedShaLabels.has(r.sourceLabel),
552
1031
  }))
553
1032
  : undefined;
554
- const { fileRef } = history.stageFileVersion(pullId, targetPath, writeTargets[i].content, allDiffs[i].isNew, sourceShaRecords);
1033
+ // For a first-seen unreadable sudo file, capture v0 via sudo so
1034
+ // revert-to-original works even when the invoking user cannot read
1035
+ // the file directly.
1036
+ let v0Override;
1037
+ let v0IsSymlinkOverride = false;
1038
+ if (allDiffs[i].isUnreadable &&
1039
+ !allDiffs[i].isNew &&
1040
+ writeTargets[i].sudo &&
1041
+ !history.getFileMeta(targetPath)) {
1042
+ if ((0, writer_1.sudoIsSymlink)(writeTargets[i].sudo, targetPath)) {
1043
+ if (writeTargets[i].symlinkTarget !== undefined) {
1044
+ // Destination is a symlink and so is the write target — read
1045
+ // the existing link target via sudoReadlink so v0 records the
1046
+ // original symlink destination for faithful revert/reset.
1047
+ const existingTarget = (0, writer_1.sudoReadlink)(writeTargets[i].sudo, targetPath);
1048
+ if (existingTarget !== null) {
1049
+ v0Override = Buffer.from(existingTarget);
1050
+ v0IsSymlinkOverride = true;
1051
+ }
1052
+ }
1053
+ // If the write target is a regular file but the destination is a
1054
+ // symlink, skip v0: sudoRead would follow the symlink and could
1055
+ // read an unintended privileged file before write-path checks run.
1056
+ }
1057
+ else {
1058
+ v0Override =
1059
+ (0, writer_1.sudoRead)(writeTargets[i].sudo, targetPath) ?? undefined;
1060
+ }
1061
+ }
1062
+ const { fileRef } = history.stageFileVersion(pullId, targetPath, writeTargets[i].content, allDiffs[i].isNew, sourceShaRecords, writeTargets[i].sudo, v0Override, !!writeTargets[i].symlinkTarget, v0IsSymlinkOverride);
555
1063
  stagedFileRefs.push(fileRef);
556
1064
  }
557
1065
  catch {
@@ -559,16 +1067,149 @@ function pullCommand() {
559
1067
  }
560
1068
  }
561
1069
  }
1070
+ let postWriteError = null;
1071
+ const effectivelyDeleted = new Set();
1072
+ const effectivelyRestored = new Set();
1073
+ // Tracks all stale paths fully resolved this pull — including no-ops
1074
+ // (file already gone, or already matches v0). Used to prune old refs
1075
+ // from history so stale cleanup does not repeat on subsequent pulls.
1076
+ const effectivelyCleaned = new Set();
562
1077
  try {
563
- const changedTargets = writeTargets.filter((_, i) => allDiffs[i].hasChanges);
564
- (0, writer_1.atomicWrite)([...changedTargets, ...staleToRestore], staleToDelete);
565
- const written = changedTargets.length + staleToRestore.length + staleToDelete.length;
1078
+ // changedTargets and sudoValues already computed above; auth already done.
1079
+ // Content writes go first so that if atomicWrite throws, no permissions
1080
+ // have been changed yet (minimises partial-apply surface).
1081
+ const isSudoTarget = (t) => !!t.sudo;
1082
+ const regularChanged = changedTargets.filter((t) => !t.sudo);
1083
+ const sudoChanged = changedTargets.filter(isSudoTarget);
1084
+ // Only restore entries whose diff still has changes (no-op restores filtered out)
1085
+ const regularRestore = activeStaleRestore.filter((t) => !t.sudo);
1086
+ const sudoRestore = activeStaleRestore.filter(isSudoTarget);
1087
+ const regularDelete = staleToDelete.filter((p) => !staleDeleteSudo.has(p));
1088
+ (0, writer_1.atomicWrite)([...regularChanged, ...regularRestore]);
1089
+ if (sudoChanged.length + sudoRestore.length > 0) {
1090
+ (0, writer_1.sudoAtomicWrite)([...sudoChanged, ...sudoRestore]);
1091
+ }
1092
+ // Mark all active stale restores as completed (atomicWrite throws on
1093
+ // failure so if we reach here all restores were written successfully).
1094
+ for (const t of activeStaleRestore) {
1095
+ effectivelyRestored.add(t.targetPath);
1096
+ effectivelyCleaned.add(t.targetPath);
1097
+ }
1098
+ // No-op stale restores (file already matches v0) are also cleaned —
1099
+ // mark them so their refs are removed from history and the cleanup
1100
+ // does not repeat on subsequent pulls.
1101
+ const activeStaleRestorePaths = new Set(activeStaleRestore.map((t) => t.targetPath));
1102
+ for (const t of staleToRestore) {
1103
+ if (!activeStaleRestorePaths.has(t.targetPath)) {
1104
+ effectivelyCleaned.add(t.targetPath);
1105
+ }
1106
+ }
1107
+ // Deletions are deferred until both write batches succeed so that
1108
+ // stale files are not removed if a later write batch fails.
1109
+ for (const p of regularDelete) {
1110
+ const idx = staleDeleteDiffIndex.get(p);
1111
+ if (idx === undefined)
1112
+ continue;
1113
+ if (!staleDiffs[idx].hasChanges) {
1114
+ // File is already gone — no-op, but still clean up its history ref.
1115
+ effectivelyCleaned.add(p);
1116
+ continue;
1117
+ }
1118
+ try {
1119
+ fs.rmSync(p, { force: true });
1120
+ effectivelyDeleted.add(p);
1121
+ effectivelyCleaned.add(p);
1122
+ }
1123
+ catch (err) {
1124
+ console.warn(`Warning: could not delete ${p}: ${err instanceof Error ? err.message : String(err)}`);
1125
+ }
1126
+ }
1127
+ for (const [p, sv] of staleDeleteSudo) {
1128
+ const idx = staleDeleteDiffIndex.get(p);
1129
+ if (idx === undefined)
1130
+ continue;
1131
+ if (!staleDiffs[idx].hasChanges) {
1132
+ // File is already gone — no-op, but still clean up its history ref.
1133
+ effectivelyCleaned.add(p);
1134
+ }
1135
+ else if ((0, writer_1.sudoDelete)(p, sv)) {
1136
+ effectivelyDeleted.add(p);
1137
+ effectivelyCleaned.add(p);
1138
+ }
1139
+ }
1140
+ // Mode-only changes: apply chmod directly (POSIX only — mode bits are
1141
+ // not meaningful on Windows so modeChange is never set there).
1142
+ let modeOnlyCount = 0;
1143
+ if (process.platform !== 'win32') {
1144
+ for (let i = 0; i < writeTargets.length; i++) {
1145
+ const d = allDiffs[i];
1146
+ if (d.modeChange && !d.contentChanged) {
1147
+ if (writeTargets[i].sudo) {
1148
+ // Use sudo for the symlink/existence checks: fs.lstatSync
1149
+ // throws EACCES on paths inside root-owned directories.
1150
+ // Skip chmod if the file has been deleted since diff
1151
+ // computation (mirrors non-sudo throwIfNoEntry: false path).
1152
+ if ((0, writer_1.sudoFileExists)(writeTargets[i].sudo, writeTargets[i].targetPath) &&
1153
+ !(0, writer_1.sudoIsSymlink)(writeTargets[i].sudo, writeTargets[i].targetPath)) {
1154
+ (0, writer_1.sudoRun)(writeTargets[i].sudo, [
1155
+ 'chmod',
1156
+ '--',
1157
+ d.modeChange.to.toString(8).padStart(4, '0'),
1158
+ writeTargets[i].targetPath,
1159
+ ]);
1160
+ modeOnlyCount++;
1161
+ }
1162
+ }
1163
+ else {
1164
+ const lst = fs.lstatSync(writeTargets[i].targetPath, {
1165
+ throwIfNoEntry: false,
1166
+ });
1167
+ if (lst && !lst.isSymbolicLink()) {
1168
+ fs.chmodSync(writeTargets[i].targetPath, d.modeChange.to);
1169
+ modeOnlyCount++;
1170
+ }
1171
+ }
1172
+ }
1173
+ }
1174
+ }
1175
+ const deletedCount = effectivelyDeleted.size;
1176
+ const written = changedTargets.length +
1177
+ activeStaleRestore.length +
1178
+ deletedCount +
1179
+ modeOnlyCount;
566
1180
  console.log(`Wrote ${written} file(s).`);
1181
+ for (const ctx of fileHookContexts) {
1182
+ if (postWriteError !== null)
1183
+ break;
1184
+ const env = {
1185
+ AVANTI_TARGET: ctx.targetPath,
1186
+ AVANTI_IS_NEW: String(ctx.isNew),
1187
+ };
1188
+ const runNamedPostHook = (key, script) => {
1189
+ if (postWriteError !== null)
1190
+ return;
1191
+ try {
1192
+ (0, on_1.runHook)(script, env);
1193
+ }
1194
+ catch (err) {
1195
+ postWriteError = `Hook ${key} failed for ${ctx.targetPath}: ${err instanceof Error ? err.message : String(err)}`;
1196
+ }
1197
+ };
1198
+ if (ctx.isNew && ctx.hooks.create)
1199
+ runNamedPostHook('create', ctx.hooks.create);
1200
+ if (!ctx.isNew && ctx.hooks.update)
1201
+ runNamedPostHook('update', ctx.hooks.update);
1202
+ }
567
1203
  }
568
1204
  catch (err) {
569
1205
  console.error(`Write failed: ${err instanceof Error ? err.message : String(err)}`);
570
1206
  process.exit(2);
571
1207
  }
1208
+ // meta.sudo is updated by stageFileVersion for every file that was
1209
+ // actually written. No extra sync needed here: updating meta.sudo for
1210
+ // no-op targets would overwrite the privilege identity from the last
1211
+ // real write with a config value that was never applied to the file,
1212
+ // causing stale cleanup to run with the wrong credentials.
572
1213
  // Save inserted fragments to history for future idempotency detection
573
1214
  if (historyAvailable && insertedFragments.size > 0) {
574
1215
  for (const [targetPath, fragment] of insertedFragments) {
@@ -587,16 +1228,38 @@ function pullCommand() {
587
1228
  console.warn(`Warning: could not update SHA values in config: ${err instanceof Error ? err.message : String(err)}`);
588
1229
  }
589
1230
  }
590
- // Only record to pulls.jsonl if at least one file was staged (written or
591
- // SHA-accepted via --accept-changes with no content diff)
592
- if (pullId && stagedFileRefs.length > 0) {
1231
+ // Record to pulls.jsonl when files were staged OR stale files were
1232
+ // deleted/restored. For stale-only runs, merge surviving refs from the
1233
+ // last pull so the cleaned-up paths are no longer listed — without this,
1234
+ // subsequent pulls see the same stale files in the last-pull log and
1235
+ // attempt the same delete/restore again on every run.
1236
+ if (pullId &&
1237
+ (stagedFileRefs.length > 0 || effectivelyCleaned.size > 0)) {
593
1238
  try {
594
- history.closePullSession(pullId, (0, config_1.normalizeConfigKey)(configPath), stagedFileRefs);
1239
+ let refsToRecord = stagedFileRefs;
1240
+ if (historyAvailable) {
1241
+ const lastFiles = history.getLastPullFiles();
1242
+ const survivingRefs = lastFiles.filter((ref) => !effectivelyCleaned.has(ref.absolutePath));
1243
+ const stagedPaths = new Set(stagedFileRefs.map((r) => r.absolutePath));
1244
+ // Preserve existing sudo on surviving refs — these files were not
1245
+ // rewritten this pull, so their on-disk ownership reflects the
1246
+ // previous write identity. Replacing with the current config value
1247
+ // would let stale cleanup run with the wrong privileges.
1248
+ refsToRecord = [
1249
+ ...survivingRefs.filter((r) => !stagedPaths.has(r.absolutePath)),
1250
+ ...stagedFileRefs,
1251
+ ];
1252
+ }
1253
+ history.closePullSession(pullId, (0, config_1.normalizeConfigKey)(configPath), refsToRecord);
595
1254
  }
596
1255
  catch {
597
1256
  console.warn('Warning: could not save pull history.');
598
1257
  }
599
1258
  }
1259
+ if (postWriteError !== null) {
1260
+ console.error(postWriteError);
1261
+ process.exit(2);
1262
+ }
600
1263
  });
601
1264
  }
602
1265
  //# sourceMappingURL=pull.js.map