@udondan/avanti 0.24.0 → 0.26.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 +488 -64
  2. package/dist/commands/diff.d.ts.map +1 -1
  3. package/dist/commands/diff.js +82 -18
  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 +6 -4
  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 +682 -42
  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 +17 -0
  25. package/dist/config-writeback.js.map +1 -1
  26. package/dist/config.d.ts +12 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +346 -3
  29. package/dist/config.js.map +1 -1
  30. package/dist/diff.d.ts +19 -0
  31. package/dist/diff.d.ts.map +1 -1
  32. package/dist/diff.js +317 -12
  33. package/dist/diff.js.map +1 -1
  34. package/dist/extract.d.ts +4 -0
  35. package/dist/extract.d.ts.map +1 -0
  36. package/dist/extract.js +142 -0
  37. package/dist/extract.js.map +1 -0
  38. package/dist/filter.d.ts +3 -0
  39. package/dist/filter.d.ts.map +1 -0
  40. package/dist/filter.js +126 -0
  41. package/dist/filter.js.map +1 -0
  42. package/dist/history.d.ts +7 -1
  43. package/dist/history.d.ts.map +1 -1
  44. package/dist/history.js +89 -9
  45. package/dist/history.js.map +1 -1
  46. package/dist/paths.d.ts +4 -0
  47. package/dist/paths.d.ts.map +1 -1
  48. package/dist/paths.js +97 -0
  49. package/dist/paths.js.map +1 -1
  50. package/dist/processors/ini.d.ts +33 -0
  51. package/dist/processors/ini.d.ts.map +1 -0
  52. package/dist/processors/ini.js +500 -0
  53. package/dist/processors/ini.js.map +1 -0
  54. package/dist/processors/insert.d.ts.map +1 -1
  55. package/dist/processors/insert.js +338 -15
  56. package/dist/processors/insert.js.map +1 -1
  57. package/dist/processors/on.d.ts +4 -0
  58. package/dist/processors/on.d.ts.map +1 -0
  59. package/dist/processors/on.js +54 -0
  60. package/dist/processors/on.js.map +1 -0
  61. package/dist/ref.d.ts +21 -0
  62. package/dist/ref.d.ts.map +1 -0
  63. package/dist/ref.js +65 -0
  64. package/dist/ref.js.map +1 -0
  65. package/dist/sources/bitbucket.d.ts.map +1 -1
  66. package/dist/sources/bitbucket.js +51 -12
  67. package/dist/sources/bitbucket.js.map +1 -1
  68. package/dist/sources/git.d.ts.map +1 -1
  69. package/dist/sources/git.js +54 -6
  70. package/dist/sources/git.js.map +1 -1
  71. package/dist/sources/github.d.ts.map +1 -1
  72. package/dist/sources/github.js +188 -51
  73. package/dist/sources/github.js.map +1 -1
  74. package/dist/sources/gitlab.d.ts.map +1 -1
  75. package/dist/sources/gitlab.js +242 -44
  76. package/dist/sources/gitlab.js.map +1 -1
  77. package/dist/sources/index.d.ts +4 -2
  78. package/dist/sources/index.d.ts.map +1 -1
  79. package/dist/sources/index.js +220 -49
  80. package/dist/sources/index.js.map +1 -1
  81. package/dist/sources/local.d.ts +3 -0
  82. package/dist/sources/local.d.ts.map +1 -1
  83. package/dist/sources/local.js +44 -0
  84. package/dist/sources/local.js.map +1 -1
  85. package/dist/types.d.ts +38 -2
  86. package/dist/types.d.ts.map +1 -1
  87. package/dist/types.js.map +1 -1
  88. package/dist/variables-remote.d.ts +1 -1
  89. package/dist/variables-remote.d.ts.map +1 -1
  90. package/dist/variables-remote.js +4 -3
  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 +37 -2
  95. package/dist/variables.js.map +1 -1
  96. package/dist/writer.d.ts +17 -0
  97. package/dist/writer.d.ts.map +1 -1
  98. package/dist/writer.js +848 -20
  99. package/dist/writer.js.map +1 -1
  100. package/package.json +14 -10
@@ -42,27 +42,29 @@ 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");
54
55
  const variables_remote_1 = require("../variables-remote");
55
56
  const variables_1 = require("../variables");
56
- async function runFetchLoop(config, workingDir, dateVars, cache, configPath, history) {
57
+ async function runFetchLoop(config, workingDir, dateVars, cache, configPath, history, configBase) {
57
58
  let vars;
58
59
  try {
59
- vars = await (0, variables_remote_1.resolveVariableSpec)(config.variables ?? {}, workingDir, cache);
60
+ vars = await (0, variables_remote_1.resolveVariableSpec)(config.variables ?? {}, workingDir, cache, configBase);
60
61
  }
61
62
  catch (err) {
62
63
  console.error(err instanceof Error ? err.message : String(err));
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);
155
179
  }
156
180
  continue;
157
181
  }
158
- const result = await (0, sources_1.fetchSource)(entry, workingDir, preVars, cache, isSelf && configPath !== undefined ? () => configPath : undefined, pendingWrites);
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
253
+ }
254
+ continue;
255
+ }
256
+ const result = await (0, sources_1.fetchSource)(entry, workingDir, preVars, cache, isSelf && configPath !== undefined ? () => configPath : undefined, pendingWrites, configBase);
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,25 +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, entry.mode);
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,
243
365
  writeInPlace: entry.writeInPlace,
366
+ sudo: entry.sudo,
244
367
  });
245
- 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
+ }
246
386
  if (result.sourceRecords.length > 0) {
247
- sourceRecordsByTarget.set(targetPath, result.sourceRecords);
387
+ sourceRecordsByTarget.set(ep, result.sourceRecords);
248
388
  }
249
389
  }
250
390
  }
@@ -256,6 +396,7 @@ async function runFetchLoop(config, workingDir, dateVars, cache, configPath, his
256
396
  return {
257
397
  writeTargets,
258
398
  allDiffs,
399
+ fileHookContexts,
259
400
  hasError,
260
401
  shaErrors,
261
402
  sourceRecordsByTarget,
@@ -264,12 +405,13 @@ async function runFetchLoop(config, workingDir, dateVars, cache, configPath, his
264
405
  insertedFragments,
265
406
  selfContent,
266
407
  selfMode,
408
+ selfSudo,
267
409
  selfSourceRecords,
268
410
  };
269
411
  }
270
412
  function printShaErrors(errors) {
271
413
  for (const e of errors) {
272
- console.error(`SHA mismatch for ${e.sourceLabel}\n` +
414
+ console.error(`SHA mismatch for ${(0, sources_1.formatSourceLabel)(e.sourceLabel)}\n` +
273
415
  ` expected: ${e.expectedSha}\n` +
274
416
  ` got: ${e.observedSha}`);
275
417
  }
@@ -284,7 +426,7 @@ function pullCommand() {
284
426
  const configPath = (0, config_1.resolveConfigPath)(cmd.parent?.opts().config);
285
427
  const rawWorkingDir = cmd.parent?.opts().workingDir;
286
428
  const workingDir = rawWorkingDir
287
- ? path.resolve(rawWorkingDir)
429
+ ? path.resolve((0, paths_1.expandTilde)(rawWorkingDir))
288
430
  : process.cwd();
289
431
  const via = (0, config_1.parseVia)(cmd.parent?.opts().via, '--via');
290
432
  let config;
@@ -300,8 +442,10 @@ function pullCommand() {
300
442
  const pullId = historyAvailable ? history.openPullSession() : null;
301
443
  const fetchCache = new Map();
302
444
  const dateVars = (0, variables_1.buildDateVars)();
303
- const firstPass = await runFetchLoop(config, workingDir, dateVars, fetchCache, configPath, history);
445
+ const configBase = (0, config_1.deriveConfigBase)(configPath);
446
+ const firstPass = await runFetchLoop(config, workingDir, dateVars, fetchCache, configPath, history, configBase);
304
447
  let { writeTargets, allDiffs, sourceRecordsByTarget } = firstPass;
448
+ let fileHookContexts = firstPass.fileHookContexts;
305
449
  let insertedFragments = firstPass.insertedFragments;
306
450
  let skippedPaths = firstPass.skippedPaths;
307
451
  let hasUnresolvableSkippedPath = firstPass.hasUnresolvableSkippedPath;
@@ -321,6 +465,7 @@ function pullCommand() {
321
465
  let prevSelfContent;
322
466
  let currentSelfContent = firstPass.selfContent;
323
467
  let currentSelfMode = firstPass.selfMode;
468
+ let currentSelfSudo = firstPass.selfSudo;
324
469
  let currentSelfSourceRecords = firstPass.selfSourceRecords;
325
470
  let stableConfig;
326
471
  while (stableConfig === undefined) {
@@ -340,7 +485,7 @@ function pullCommand() {
340
485
  break;
341
486
  }
342
487
  console.log('$self config resolved; re-evaluating with merged config...');
343
- const next = await runFetchLoop(currentConfig, workingDir, dateVars, fetchCache, configPath, history);
488
+ const next = await runFetchLoop(currentConfig, workingDir, dateVars, fetchCache, configPath, history, configBase);
344
489
  if (next.hasError) {
345
490
  console.error('Aborting due to errors in $self re-evaluated config.');
346
491
  process.exit(2);
@@ -362,6 +507,7 @@ function pullCommand() {
362
507
  prevSelfContent = currentSelfContent;
363
508
  currentSelfContent = next.selfContent;
364
509
  currentSelfMode = next.selfMode;
510
+ currentSelfSudo = next.selfSudo;
365
511
  currentSelfSourceRecords = next.selfSourceRecords;
366
512
  }
367
513
  if (stableConfig !== undefined) {
@@ -374,7 +520,7 @@ function pullCommand() {
374
520
  filesWithoutSelf[k] = v;
375
521
  }
376
522
  if (Object.keys(filesWithoutSelf).length > 0) {
377
- const second = await runFetchLoop({ ...stableConfig, files: filesWithoutSelf }, workingDir, dateVars, fetchCache, configPath, history);
523
+ const second = await runFetchLoop({ ...stableConfig, files: filesWithoutSelf }, workingDir, dateVars, fetchCache, configPath, history, configBase);
378
524
  if (second.hasError) {
379
525
  console.error('Aborting due to errors.');
380
526
  process.exit(2);
@@ -386,6 +532,7 @@ function pullCommand() {
386
532
  }
387
533
  writeTargets = second.writeTargets;
388
534
  allDiffs = second.allDiffs;
535
+ fileHookContexts = second.fileHookContexts;
389
536
  sourceRecordsByTarget = second.sourceRecordsByTarget;
390
537
  insertedFragments = second.insertedFragments;
391
538
  skippedPaths = second.skippedPaths;
@@ -409,6 +556,7 @@ function pullCommand() {
409
556
  targetPath: configPath,
410
557
  content: selfBuf,
411
558
  mode: currentSelfMode,
559
+ sudo: currentSelfSudo,
412
560
  });
413
561
  allDiffs.push((0, diff_1.computeDiff)(configPath, selfBuf, currentSelfMode));
414
562
  }
@@ -418,6 +566,10 @@ function pullCommand() {
418
566
  ...writeTargets[existingIdx],
419
567
  content: selfBuf,
420
568
  mode: resolvedSelfMode,
569
+ // Fall back to the existing target's sudo identity when $self
570
+ // doesn't specify one, so a privileged config file keeps its
571
+ // write privileges after $self stabilization.
572
+ sudo: currentSelfSudo ?? writeTargets[existingIdx].sudo,
421
573
  };
422
574
  allDiffs[existingIdx] = (0, diff_1.computeDiff)(configPath, selfBuf, resolvedSelfMode);
423
575
  }
@@ -434,8 +586,15 @@ function pullCommand() {
434
586
  }
435
587
  // Detect stale files: present in last pull but no longer in current source fetch
436
588
  const staleToDelete = [];
589
+ // Maps path → sudo identity for stale files that need privileged deletion
590
+ const staleDeleteSudo = new Map();
591
+ // Maps path → staleDiffs index so we can check hasChanges before auth/write
592
+ const staleDeleteDiffIndex = new Map();
437
593
  const staleToRestore = [];
438
594
  const staleDiffs = [];
595
+ // Parallel array: staleDiffs index for each staleToRestore entry
596
+ const staleRestoreDiffIndices = [];
597
+ let staleHasError = false;
439
598
  if (historyAvailable && !hasUnresolvableSkippedPath) {
440
599
  const lastFiles = history.getLastPullFiles();
441
600
  const currentPaths = new Set(writeTargets.map((t) => t.targetPath));
@@ -457,22 +616,253 @@ function pullCommand() {
457
616
  if (meta.existedBeforeAvanti) {
458
617
  const original = history.readVersion(ref.absolutePath, 0);
459
618
  if (original !== null) {
460
- staleToRestore.push({
461
- targetPath: ref.absolutePath,
462
- content: original,
463
- });
464
- staleDiffs.push((0, diff_1.computeDiff)(ref.absolutePath, original));
619
+ if (meta.v0IsSymlink) {
620
+ if (process.platform === 'win32') {
621
+ console.error(`symlink: ${ref.absolutePath}: cannot restore pre-avanti symlink on Windows`);
622
+ staleHasError = true;
623
+ // Show the actual stored target so the user knows what cannot
624
+ // be restored. No staleRestoreDiffIndices push — there is no
625
+ // corresponding staleToRestore entry for error-only diffs.
626
+ const symlinkTarget = original.toString('utf8');
627
+ const warnDiff = (0, diff_1.buildNewSymlinkDiff)(ref.absolutePath, symlinkTarget);
628
+ staleDiffs.push({ ...warnDiff, hasChanges: true });
629
+ }
630
+ else {
631
+ const symlinkTarget = original.toString('utf8');
632
+ const staleDiff = (0, diff_1.computeSymlinkDiff)(ref.absolutePath, symlinkTarget);
633
+ if (staleDiff.isDirectory) {
634
+ console.error(`symlink: ${ref.absolutePath} is a directory; cannot restore symlink over directory`);
635
+ staleHasError = true;
636
+ // No staleRestoreDiffIndices push — no corresponding
637
+ // staleToRestore entry for this error-only diff.
638
+ staleDiffs.push(staleDiff);
639
+ }
640
+ else if (staleDiff.hasChanges) {
641
+ staleToRestore.push({
642
+ targetPath: ref.absolutePath,
643
+ content: original,
644
+ symlinkTarget,
645
+ sudo: meta.sudo,
646
+ });
647
+ staleRestoreDiffIndices.push(staleDiffs.length);
648
+ staleDiffs.push(staleDiff);
649
+ }
650
+ }
651
+ }
652
+ else {
653
+ staleToRestore.push({
654
+ targetPath: ref.absolutePath,
655
+ content: original,
656
+ sudo: meta.sudo,
657
+ });
658
+ staleRestoreDiffIndices.push(staleDiffs.length);
659
+ const staleDiff = (0, diff_1.computeDiff)(ref.absolutePath, original);
660
+ // A missing file with empty v0 produces isNew=true but
661
+ // hasChanges=false and an empty patch, so formatDiff returns ''.
662
+ // Rebuild as a proper new-file diff so the confirmation output
663
+ // shows the recreate action and the patch is consistent.
664
+ staleDiffs.push(staleDiff.isNew
665
+ ? (0, diff_1.buildNewFileDiff)(ref.absolutePath, original)
666
+ : staleDiff);
667
+ }
668
+ }
669
+ else {
670
+ console.warn(`Warning: cannot restore original for ${ref.absolutePath} — v0 was never captured (file was unreadable at first pull). Leaving file unchanged.`);
465
671
  }
466
672
  }
467
673
  else {
468
674
  staleToDelete.push(ref.absolutePath);
675
+ if (meta.sudo)
676
+ staleDeleteSudo.set(ref.absolutePath, meta.sudo);
677
+ staleDeleteDiffIndex.set(ref.absolutePath, staleDiffs.length);
469
678
  staleDiffs.push((0, diff_1.computeDeleteDiff)(ref.absolutePath));
470
679
  }
471
680
  }
472
681
  }
682
+ // Windows does not have sudo; fail before any authentication attempt.
683
+ if (process.platform === 'win32' &&
684
+ (writeTargets.some((t) => t.sudo) ||
685
+ staleToRestore.some((t) => t.sudo) ||
686
+ staleDeleteSudo.size > 0)) {
687
+ console.error('sudo is not supported on Windows');
688
+ process.exit(2);
689
+ }
690
+ // Authenticate early for unreadable sudo files so we can read their actual
691
+ // content before deciding whether anything changed. Without this, every pull
692
+ // would show unreadable files as "changed" (we can't diff without reading),
693
+ // and "Nothing to do." could never be reported on a re-run.
694
+ const unreadableSudoValues = new Set();
695
+ for (let i = 0; i < writeTargets.length; i++) {
696
+ if (allDiffs[i].isUnreadable && writeTargets[i].sudo) {
697
+ unreadableSudoValues.add(writeTargets[i].sudo);
698
+ }
699
+ }
700
+ // Also auth for unreadable stale restore targets that need sudo.
701
+ for (let i = 0; i < staleToRestore.length; i++) {
702
+ const diffIdx = staleRestoreDiffIndices[i];
703
+ if (staleDiffs[diffIdx]?.isUnreadable && staleToRestore[i].sudo) {
704
+ unreadableSudoValues.add(staleToRestore[i].sudo);
705
+ }
706
+ }
707
+ const authenticatedSudoIds = new Set();
708
+ for (const sv of unreadableSudoValues) {
709
+ try {
710
+ (0, writer_1.sudoAuth)(sv);
711
+ }
712
+ catch (err) {
713
+ console.error(err instanceof Error ? err.message : String(err));
714
+ process.exit(2);
715
+ }
716
+ authenticatedSudoIds.add(sv);
717
+ }
718
+ // For entries where lstatSync failed (parent directory not searchable),
719
+ // use sudoFileExists to determine whether the file actually exists so
720
+ // existedBeforeAvanti is recorded correctly. Also compute modeChange now
721
+ // that we have sudo access (computeDiff could not stat the file pre-auth).
722
+ for (let i = 0; i < writeTargets.length; i++) {
723
+ if (allDiffs[i].lstatFailed && writeTargets[i].sudo) {
724
+ const exists = (0, writer_1.sudoFileExists)(writeTargets[i].sudo, writeTargets[i].targetPath);
725
+ const isNew = !exists;
726
+ let modeChange = allDiffs[i].modeChange;
727
+ if (exists && writeTargets[i].mode) {
728
+ const curModeStr = (0, writer_1.getSudoFileMode)(writeTargets[i].sudo, writeTargets[i].targetPath);
729
+ if (curModeStr !== undefined) {
730
+ const desired = parseInt(writeTargets[i].mode, 8);
731
+ const cur = parseInt(curModeStr, 8);
732
+ if (!isNaN(desired) && !isNaN(cur) && desired !== cur) {
733
+ modeChange = { from: cur, to: desired };
734
+ }
735
+ }
736
+ }
737
+ if (isNew) {
738
+ // File confirmed absent — rebuild as a proper new diff so
739
+ // formatDiff shows the actual content instead of "unreadable".
740
+ // Clear backupPath: it was set assuming the file existed (conservative
741
+ // lstatFailed default). Since the file is actually new, there is
742
+ // nothing to back up and the backup should not be created.
743
+ if (writeTargets[i].symlinkTarget !== undefined) {
744
+ // Symlink entry — use buildNewSymlinkDiff instead of
745
+ // computeSymlinkDiff: the parent dir is still not searchable
746
+ // (EACCES), so calling computeSymlinkDiff would lstatSync-fail
747
+ // again and return isUnreadable rather than isNew:true.
748
+ allDiffs[i] = (0, diff_1.buildNewSymlinkDiff)(allDiffs[i].targetPath, writeTargets[i].symlinkTarget);
749
+ }
750
+ else {
751
+ allDiffs[i] = (0, diff_1.buildNewFileDiff)(allDiffs[i].targetPath, writeTargets[i].content, modeChange);
752
+ }
753
+ writeTargets[i] = { ...writeTargets[i], backupPath: undefined };
754
+ }
755
+ else {
756
+ let updatedDiff = { ...allDiffs[i], isNew, modeChange };
757
+ // For symlink entries, check whether the existing path is a real
758
+ // directory — ln -sf would place the symlink inside it rather than
759
+ // replacing it, so detect this now and surface an error before the
760
+ // write batch is attempted.
761
+ if (writeTargets[i].symlinkTarget !== undefined) {
762
+ const isSymlinkAtTarget = (0, writer_1.sudoIsSymlink)(writeTargets[i].sudo, writeTargets[i].targetPath);
763
+ if (!isSymlinkAtTarget &&
764
+ (0, writer_1.sudoIsDirectory)(writeTargets[i].sudo, writeTargets[i].targetPath)) {
765
+ updatedDiff = { ...updatedDiff, isDirectory: true };
766
+ }
767
+ }
768
+ allDiffs[i] = updatedDiff;
769
+ }
770
+ // Propagate corrected isNew to the hook context so lifecycle hooks
771
+ // receive the correct AVANTI_IS_NEW value.
772
+ const hookIdx = fileHookContexts.findIndex((ctx) => ctx.targetPath === writeTargets[i].targetPath);
773
+ if (hookIdx >= 0) {
774
+ fileHookContexts[hookIdx] = { ...fileHookContexts[hookIdx], isNew };
775
+ }
776
+ }
777
+ }
778
+ // Fail fast if any symlink write target is a real directory: ln -sf would
779
+ // place the symlink inside it rather than replacing it, so abort before
780
+ // prompting the user rather than failing mid-write-batch.
781
+ for (const d of allDiffs) {
782
+ if (d.isDirectory && d.isSymlink) {
783
+ console.error(`symlink: ${d.targetPath} is a directory; cannot replace with a symlink`);
784
+ }
785
+ }
786
+ if (allDiffs.some((d) => d.isDirectory && d.isSymlink)) {
787
+ process.exit(2);
788
+ }
789
+ // Post-auth idempotency: compare current file content via sudo against the
790
+ // desired content. If they match, suppress the write for this entry.
791
+ for (let i = 0; i < writeTargets.length; i++) {
792
+ if (allDiffs[i].isUnreadable && writeTargets[i].sudo) {
793
+ if (writeTargets[i].symlinkTarget !== undefined) {
794
+ // Symlink entry: use sudo readlink to check whether the on-disk
795
+ // symlink already points to the desired target. If it does, the
796
+ // write is a no-op and the diff should be suppressed.
797
+ const current = (0, writer_1.sudoReadlink)(writeTargets[i].sudo, writeTargets[i].targetPath);
798
+ if (current !== null && current === writeTargets[i].symlinkTarget) {
799
+ const updatedHasChanges = allDiffs[i].modeChange !== undefined;
800
+ allDiffs[i] = {
801
+ ...allDiffs[i],
802
+ contentChanged: false,
803
+ hasChanges: updatedHasChanges,
804
+ };
805
+ if (!updatedHasChanges) {
806
+ const hookIdx = fileHookContexts.findIndex((ctx) => ctx.targetPath === writeTargets[i].targetPath);
807
+ if (hookIdx >= 0) {
808
+ fileHookContexts.splice(hookIdx, 1);
809
+ }
810
+ }
811
+ }
812
+ continue;
813
+ }
814
+ // For regular file entries: skip idempotency read when the existing
815
+ // path is a symlink — sudoRead follows symlinks and could compare
816
+ // against the wrong content (the symlink target's bytes, not the
817
+ // symlink itself).
818
+ if ((0, writer_1.sudoIsSymlink)(writeTargets[i].sudo, writeTargets[i].targetPath)) {
819
+ continue;
820
+ }
821
+ const current = (0, writer_1.sudoRead)(writeTargets[i].sudo, writeTargets[i].targetPath);
822
+ if (current !== null && current.equals(writeTargets[i].content)) {
823
+ const updatedHasChanges = allDiffs[i].modeChange !== undefined;
824
+ allDiffs[i] = {
825
+ ...allDiffs[i],
826
+ contentChanged: false,
827
+ hasChanges: updatedHasChanges,
828
+ };
829
+ // Only remove hook context when the diff is a true no-op (no mode
830
+ // change either). A mode-only change still triggers before/afterUpdate
831
+ // hooks, so the context must remain for those cases.
832
+ if (!updatedHasChanges) {
833
+ const hookIdx = fileHookContexts.findIndex((ctx) => ctx.targetPath === writeTargets[i].targetPath);
834
+ if (hookIdx >= 0) {
835
+ fileHookContexts.splice(hookIdx, 1);
836
+ }
837
+ }
838
+ }
839
+ }
840
+ }
841
+ // Same idempotency check for stale restore targets: if the current file
842
+ // content already matches the v0 original, suppress the redundant write.
843
+ for (let i = 0; i < staleToRestore.length; i++) {
844
+ const diffIdx = staleRestoreDiffIndices[i];
845
+ if (staleDiffs[diffIdx]?.isUnreadable && staleToRestore[i].sudo) {
846
+ // Skip idempotency read for symlinks: sudoRead follows symlinks and
847
+ // could read an unintended privileged file before write-path checks run.
848
+ if ((0, writer_1.sudoIsSymlink)(staleToRestore[i].sudo, staleToRestore[i].targetPath)) {
849
+ continue;
850
+ }
851
+ const current = (0, writer_1.sudoRead)(staleToRestore[i].sudo, staleToRestore[i].targetPath);
852
+ if (current !== null && current.equals(staleToRestore[i].content)) {
853
+ staleDiffs[diffIdx] = {
854
+ ...staleDiffs[diffIdx],
855
+ contentChanged: false,
856
+ hasChanges: staleDiffs[diffIdx].modeChange !== undefined,
857
+ };
858
+ }
859
+ }
860
+ }
473
861
  const hasChanges = allDiffs.some((d) => d.hasChanges) ||
474
862
  staleDiffs.some((d) => d.hasChanges);
475
863
  (0, diff_1.printDiffs)([...allDiffs, ...staleDiffs]);
864
+ if (staleHasError)
865
+ process.exit(2);
476
866
  // Show SHA mismatch summary when using --accept-changes
477
867
  if (opts.acceptChanges && firstPass.shaErrors.length > 0) {
478
868
  console.error('');
@@ -484,6 +874,26 @@ function pullCommand() {
484
874
  history.saveInsertedFragment(targetPath, fragment.raw, fragment.processed);
485
875
  }
486
876
  }
877
+ // Prune no-op stale refs from the pull log. When all diffs are clean,
878
+ // every stale entry (delete or restore) was already resolved outside of
879
+ // avanti (file manually deleted, or content already matches v0). Without
880
+ // this, those refs remain in the last-pull log and can incorrectly flag
881
+ // a future file at the same path as stale.
882
+ if (pullId &&
883
+ historyAvailable &&
884
+ (staleToDelete.length > 0 || staleToRestore.length > 0)) {
885
+ const noopStalePaths = new Set([
886
+ ...staleToDelete,
887
+ ...staleToRestore.map((t) => t.targetPath),
888
+ ]);
889
+ const lastFiles = history.getLastPullFiles();
890
+ const survivingRefs = lastFiles.filter((ref) => !noopStalePaths.has(ref.absolutePath));
891
+ // Preserve existing sudo on surviving refs — no file was written, so
892
+ // the on-disk ownership reflects the previous write identity, not the
893
+ // current config. Overwriting here would let stale cleanup run with
894
+ // the wrong privileges for a file it never re-wrote.
895
+ history.closePullSession(pullId, (0, config_1.normalizeConfigKey)(configPath), survivingRefs);
896
+ }
487
897
  console.log('Nothing to do.');
488
898
  process.exit(0);
489
899
  }
@@ -531,6 +941,74 @@ function pullCommand() {
531
941
  }
532
942
  }
533
943
  }
944
+ for (const ctx of fileHookContexts) {
945
+ const env = {
946
+ AVANTI_TARGET: ctx.targetPath,
947
+ AVANTI_IS_NEW: String(ctx.isNew),
948
+ };
949
+ const runNamedHook = (key, script) => {
950
+ try {
951
+ (0, on_1.runHook)(script, env);
952
+ }
953
+ catch (err) {
954
+ console.error(`Hook ${key} failed for ${ctx.targetPath}: ${err instanceof Error ? err.message : String(err)}`);
955
+ process.exit(2);
956
+ }
957
+ };
958
+ if (ctx.hooks.beforeWrite)
959
+ runNamedHook('beforeWrite', ctx.hooks.beforeWrite);
960
+ if (ctx.isNew && ctx.hooks.beforeCreate)
961
+ runNamedHook('beforeCreate', ctx.hooks.beforeCreate);
962
+ if (!ctx.isNew && ctx.hooks.beforeUpdate)
963
+ runNamedHook('beforeUpdate', ctx.hooks.beforeUpdate);
964
+ }
965
+ // Compute write batches and authenticate before staging so that sudo is
966
+ // available for v0 capture of unreadable first-seen files (history must
967
+ // record the original content before it is overwritten).
968
+ const changedTargets = writeTargets.filter((_, i) => allDiffs[i].hasChanges && allDiffs[i].contentChanged);
969
+ // Only include stale restore targets whose diff still has changes (not
970
+ // suppressed by the idempotency check above). Also include diffs where
971
+ // isNew is true: a missing file with empty v0 produces hasChanges=false
972
+ // ('' !== '' = false) but still needs to be written.
973
+ const activeStaleRestoreIndices = staleToRestore
974
+ .map((_, i) => i)
975
+ .filter((i) => {
976
+ const d = staleDiffs[staleRestoreDiffIndices[i]];
977
+ return d?.hasChanges || d?.isNew;
978
+ });
979
+ const activeStaleRestore = activeStaleRestoreIndices.map((i) => staleToRestore[i]);
980
+ const sudoValues = new Set([...changedTargets, ...activeStaleRestore]
981
+ .map((t) => t.sudo)
982
+ .filter(Boolean));
983
+ // Only include stale delete sudo identities when the delete diff still
984
+ // has changes (file wasn't already absent at diff time).
985
+ for (const [p, sv] of staleDeleteSudo) {
986
+ const idx = staleDeleteDiffIndex.get(p);
987
+ if (idx !== undefined && staleDiffs[idx].hasChanges) {
988
+ sudoValues.add(sv);
989
+ }
990
+ }
991
+ for (let i = 0; i < writeTargets.length; i++) {
992
+ if (allDiffs[i].modeChange &&
993
+ !allDiffs[i].contentChanged &&
994
+ writeTargets[i].sudo) {
995
+ sudoValues.add(writeTargets[i].sudo);
996
+ }
997
+ }
998
+ // Skip identities already authenticated in the early unreadable-file pass
999
+ // so a single pull session never re-prompts for the same identity.
1000
+ for (const sv of sudoValues) {
1001
+ if (!authenticatedSudoIds.has(sv)) {
1002
+ try {
1003
+ (0, writer_1.sudoAuth)(sv);
1004
+ }
1005
+ catch (err) {
1006
+ console.error(err instanceof Error ? err.message : String(err));
1007
+ process.exit(2);
1008
+ }
1009
+ authenticatedSudoIds.add(sv);
1010
+ }
1011
+ }
534
1012
  // Stage history versions before atomicWrite so v0 is captured before overwrite
535
1013
  const stagedFileRefs = [];
536
1014
  if (pullId) {
@@ -553,7 +1031,36 @@ function pullCommand() {
553
1031
  acceptedShaLabels.has(r.sourceLabel),
554
1032
  }))
555
1033
  : undefined;
556
- const { fileRef } = history.stageFileVersion(pullId, targetPath, writeTargets[i].content, allDiffs[i].isNew, sourceShaRecords);
1034
+ // For a first-seen unreadable sudo file, capture v0 via sudo so
1035
+ // revert-to-original works even when the invoking user cannot read
1036
+ // the file directly.
1037
+ let v0Override;
1038
+ let v0IsSymlinkOverride = false;
1039
+ if (allDiffs[i].isUnreadable &&
1040
+ !allDiffs[i].isNew &&
1041
+ writeTargets[i].sudo &&
1042
+ !history.getFileMeta(targetPath)) {
1043
+ if ((0, writer_1.sudoIsSymlink)(writeTargets[i].sudo, targetPath)) {
1044
+ if (writeTargets[i].symlinkTarget !== undefined) {
1045
+ // Destination is a symlink and so is the write target — read
1046
+ // the existing link target via sudoReadlink so v0 records the
1047
+ // original symlink destination for faithful revert/reset.
1048
+ const existingTarget = (0, writer_1.sudoReadlink)(writeTargets[i].sudo, targetPath);
1049
+ if (existingTarget !== null) {
1050
+ v0Override = Buffer.from(existingTarget);
1051
+ v0IsSymlinkOverride = true;
1052
+ }
1053
+ }
1054
+ // If the write target is a regular file but the destination is a
1055
+ // symlink, skip v0: sudoRead would follow the symlink and could
1056
+ // read an unintended privileged file before write-path checks run.
1057
+ }
1058
+ else {
1059
+ v0Override =
1060
+ (0, writer_1.sudoRead)(writeTargets[i].sudo, targetPath) ?? undefined;
1061
+ }
1062
+ }
1063
+ const { fileRef } = history.stageFileVersion(pullId, targetPath, writeTargets[i].content, allDiffs[i].isNew, sourceShaRecords, writeTargets[i].sudo, v0Override, !!writeTargets[i].symlinkTarget, v0IsSymlinkOverride);
557
1064
  stagedFileRefs.push(fileRef);
558
1065
  }
559
1066
  catch {
@@ -561,11 +1068,76 @@ function pullCommand() {
561
1068
  }
562
1069
  }
563
1070
  }
1071
+ let postWriteError = null;
1072
+ const effectivelyDeleted = new Set();
1073
+ const effectivelyRestored = new Set();
1074
+ // Tracks all stale paths fully resolved this pull — including no-ops
1075
+ // (file already gone, or already matches v0). Used to prune old refs
1076
+ // from history so stale cleanup does not repeat on subsequent pulls.
1077
+ const effectivelyCleaned = new Set();
564
1078
  try {
565
- const changedTargets = writeTargets.filter((_, i) => allDiffs[i].hasChanges && allDiffs[i].contentChanged);
1079
+ // changedTargets and sudoValues already computed above; auth already done.
566
1080
  // Content writes go first so that if atomicWrite throws, no permissions
567
1081
  // have been changed yet (minimises partial-apply surface).
568
- (0, writer_1.atomicWrite)([...changedTargets, ...staleToRestore], staleToDelete);
1082
+ const isSudoTarget = (t) => !!t.sudo;
1083
+ const regularChanged = changedTargets.filter((t) => !t.sudo);
1084
+ const sudoChanged = changedTargets.filter(isSudoTarget);
1085
+ // Only restore entries whose diff still has changes (no-op restores filtered out)
1086
+ const regularRestore = activeStaleRestore.filter((t) => !t.sudo);
1087
+ const sudoRestore = activeStaleRestore.filter(isSudoTarget);
1088
+ const regularDelete = staleToDelete.filter((p) => !staleDeleteSudo.has(p));
1089
+ (0, writer_1.atomicWrite)([...regularChanged, ...regularRestore]);
1090
+ if (sudoChanged.length + sudoRestore.length > 0) {
1091
+ (0, writer_1.sudoAtomicWrite)([...sudoChanged, ...sudoRestore]);
1092
+ }
1093
+ // Mark all active stale restores as completed (atomicWrite throws on
1094
+ // failure so if we reach here all restores were written successfully).
1095
+ for (const t of activeStaleRestore) {
1096
+ effectivelyRestored.add(t.targetPath);
1097
+ effectivelyCleaned.add(t.targetPath);
1098
+ }
1099
+ // No-op stale restores (file already matches v0) are also cleaned —
1100
+ // mark them so their refs are removed from history and the cleanup
1101
+ // does not repeat on subsequent pulls.
1102
+ const activeStaleRestorePaths = new Set(activeStaleRestore.map((t) => t.targetPath));
1103
+ for (const t of staleToRestore) {
1104
+ if (!activeStaleRestorePaths.has(t.targetPath)) {
1105
+ effectivelyCleaned.add(t.targetPath);
1106
+ }
1107
+ }
1108
+ // Deletions are deferred until both write batches succeed so that
1109
+ // stale files are not removed if a later write batch fails.
1110
+ for (const p of regularDelete) {
1111
+ const idx = staleDeleteDiffIndex.get(p);
1112
+ if (idx === undefined)
1113
+ continue;
1114
+ if (!staleDiffs[idx].hasChanges) {
1115
+ // File is already gone — no-op, but still clean up its history ref.
1116
+ effectivelyCleaned.add(p);
1117
+ continue;
1118
+ }
1119
+ try {
1120
+ fs.rmSync(p, { force: true });
1121
+ effectivelyDeleted.add(p);
1122
+ effectivelyCleaned.add(p);
1123
+ }
1124
+ catch (err) {
1125
+ console.warn(`Warning: could not delete ${p}: ${err instanceof Error ? err.message : String(err)}`);
1126
+ }
1127
+ }
1128
+ for (const [p, sv] of staleDeleteSudo) {
1129
+ const idx = staleDeleteDiffIndex.get(p);
1130
+ if (idx === undefined)
1131
+ continue;
1132
+ if (!staleDiffs[idx].hasChanges) {
1133
+ // File is already gone — no-op, but still clean up its history ref.
1134
+ effectivelyCleaned.add(p);
1135
+ }
1136
+ else if ((0, writer_1.sudoDelete)(p, sv)) {
1137
+ effectivelyDeleted.add(p);
1138
+ effectivelyCleaned.add(p);
1139
+ }
1140
+ }
569
1141
  // Mode-only changes: apply chmod directly (POSIX only — mode bits are
570
1142
  // not meaningful on Windows so modeChange is never set there).
571
1143
  let modeOnlyCount = 0;
@@ -573,26 +1145,72 @@ function pullCommand() {
573
1145
  for (let i = 0; i < writeTargets.length; i++) {
574
1146
  const d = allDiffs[i];
575
1147
  if (d.modeChange && !d.contentChanged) {
576
- const lst = fs.lstatSync(writeTargets[i].targetPath, {
577
- throwIfNoEntry: false,
578
- });
579
- if (lst && !lst.isSymbolicLink()) {
580
- fs.chmodSync(writeTargets[i].targetPath, d.modeChange.to);
581
- modeOnlyCount++;
1148
+ if (writeTargets[i].sudo) {
1149
+ // Use sudo for the symlink/existence checks: fs.lstatSync
1150
+ // throws EACCES on paths inside root-owned directories.
1151
+ // Skip chmod if the file has been deleted since diff
1152
+ // computation (mirrors non-sudo throwIfNoEntry: false path).
1153
+ if ((0, writer_1.sudoFileExists)(writeTargets[i].sudo, writeTargets[i].targetPath) &&
1154
+ !(0, writer_1.sudoIsSymlink)(writeTargets[i].sudo, writeTargets[i].targetPath)) {
1155
+ (0, writer_1.sudoRun)(writeTargets[i].sudo, [
1156
+ 'chmod',
1157
+ '--',
1158
+ d.modeChange.to.toString(8).padStart(4, '0'),
1159
+ writeTargets[i].targetPath,
1160
+ ]);
1161
+ modeOnlyCount++;
1162
+ }
1163
+ }
1164
+ else {
1165
+ const lst = fs.lstatSync(writeTargets[i].targetPath, {
1166
+ throwIfNoEntry: false,
1167
+ });
1168
+ if (lst && !lst.isSymbolicLink()) {
1169
+ fs.chmodSync(writeTargets[i].targetPath, d.modeChange.to);
1170
+ modeOnlyCount++;
1171
+ }
582
1172
  }
583
1173
  }
584
1174
  }
585
1175
  }
1176
+ const deletedCount = effectivelyDeleted.size;
586
1177
  const written = changedTargets.length +
587
- staleToRestore.length +
588
- staleToDelete.length +
1178
+ activeStaleRestore.length +
1179
+ deletedCount +
589
1180
  modeOnlyCount;
590
1181
  console.log(`Wrote ${written} file(s).`);
1182
+ for (const ctx of fileHookContexts) {
1183
+ if (postWriteError !== null)
1184
+ break;
1185
+ const env = {
1186
+ AVANTI_TARGET: ctx.targetPath,
1187
+ AVANTI_IS_NEW: String(ctx.isNew),
1188
+ };
1189
+ const runNamedPostHook = (key, script) => {
1190
+ if (postWriteError !== null)
1191
+ return;
1192
+ try {
1193
+ (0, on_1.runHook)(script, env);
1194
+ }
1195
+ catch (err) {
1196
+ postWriteError = `Hook ${key} failed for ${ctx.targetPath}: ${err instanceof Error ? err.message : String(err)}`;
1197
+ }
1198
+ };
1199
+ if (ctx.isNew && ctx.hooks.create)
1200
+ runNamedPostHook('create', ctx.hooks.create);
1201
+ if (!ctx.isNew && ctx.hooks.update)
1202
+ runNamedPostHook('update', ctx.hooks.update);
1203
+ }
591
1204
  }
592
1205
  catch (err) {
593
1206
  console.error(`Write failed: ${err instanceof Error ? err.message : String(err)}`);
594
1207
  process.exit(2);
595
1208
  }
1209
+ // meta.sudo is updated by stageFileVersion for every file that was
1210
+ // actually written. No extra sync needed here: updating meta.sudo for
1211
+ // no-op targets would overwrite the privilege identity from the last
1212
+ // real write with a config value that was never applied to the file,
1213
+ // causing stale cleanup to run with the wrong credentials.
596
1214
  // Save inserted fragments to history for future idempotency detection
597
1215
  if (historyAvailable && insertedFragments.size > 0) {
598
1216
  for (const [targetPath, fragment] of insertedFragments) {
@@ -611,16 +1229,38 @@ function pullCommand() {
611
1229
  console.warn(`Warning: could not update SHA values in config: ${err instanceof Error ? err.message : String(err)}`);
612
1230
  }
613
1231
  }
614
- // Only record to pulls.jsonl if at least one file was staged (written or
615
- // SHA-accepted via --accept-changes with no content diff)
616
- if (pullId && stagedFileRefs.length > 0) {
1232
+ // Record to pulls.jsonl when files were staged OR stale files were
1233
+ // deleted/restored. For stale-only runs, merge surviving refs from the
1234
+ // last pull so the cleaned-up paths are no longer listed — without this,
1235
+ // subsequent pulls see the same stale files in the last-pull log and
1236
+ // attempt the same delete/restore again on every run.
1237
+ if (pullId &&
1238
+ (stagedFileRefs.length > 0 || effectivelyCleaned.size > 0)) {
617
1239
  try {
618
- history.closePullSession(pullId, (0, config_1.normalizeConfigKey)(configPath), stagedFileRefs);
1240
+ let refsToRecord = stagedFileRefs;
1241
+ if (historyAvailable) {
1242
+ const lastFiles = history.getLastPullFiles();
1243
+ const survivingRefs = lastFiles.filter((ref) => !effectivelyCleaned.has(ref.absolutePath));
1244
+ const stagedPaths = new Set(stagedFileRefs.map((r) => r.absolutePath));
1245
+ // Preserve existing sudo on surviving refs — these files were not
1246
+ // rewritten this pull, so their on-disk ownership reflects the
1247
+ // previous write identity. Replacing with the current config value
1248
+ // would let stale cleanup run with the wrong privileges.
1249
+ refsToRecord = [
1250
+ ...survivingRefs.filter((r) => !stagedPaths.has(r.absolutePath)),
1251
+ ...stagedFileRefs,
1252
+ ];
1253
+ }
1254
+ history.closePullSession(pullId, (0, config_1.normalizeConfigKey)(configPath), refsToRecord);
619
1255
  }
620
1256
  catch {
621
1257
  console.warn('Warning: could not save pull history.');
622
1258
  }
623
1259
  }
1260
+ if (postWriteError !== null) {
1261
+ console.error(postWriteError);
1262
+ process.exit(2);
1263
+ }
624
1264
  });
625
1265
  }
626
1266
  //# sourceMappingURL=pull.js.map