@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.
- package/README.md +578 -65
- package/dist/commands/diff.d.ts.map +1 -1
- package/dist/commands/diff.js +82 -15
- package/dist/commands/diff.js.map +1 -1
- package/dist/commands/lock.d.ts.map +1 -1
- package/dist/commands/lock.js +3 -2
- package/dist/commands/lock.js.map +1 -1
- package/dist/commands/log.d.ts.map +1 -1
- package/dist/commands/log.js +7 -5
- package/dist/commands/log.js.map +1 -1
- package/dist/commands/pull.d.ts.map +1 -1
- package/dist/commands/pull.js +695 -32
- package/dist/commands/pull.js.map +1 -1
- package/dist/commands/reset.d.ts.map +1 -1
- package/dist/commands/reset.js +43 -11
- package/dist/commands/reset.js.map +1 -1
- package/dist/commands/revert.d.ts.map +1 -1
- package/dist/commands/revert.js +68 -14
- package/dist/commands/revert.js.map +1 -1
- package/dist/condition.d.ts.map +1 -1
- package/dist/condition.js +9 -6
- package/dist/condition.js.map +1 -1
- package/dist/config-writeback.d.ts.map +1 -1
- package/dist/config-writeback.js +33 -4
- package/dist/config-writeback.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +396 -10
- package/dist/config.js.map +1 -1
- package/dist/diff.d.ts +25 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +410 -28
- package/dist/diff.js.map +1 -1
- package/dist/extract.d.ts +4 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +142 -0
- package/dist/extract.js.map +1 -0
- package/dist/filter.d.ts +3 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +126 -0
- package/dist/filter.js.map +1 -0
- package/dist/history.d.ts +7 -1
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +89 -9
- package/dist/history.js.map +1 -1
- package/dist/paths.d.ts +4 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +97 -0
- package/dist/paths.js.map +1 -1
- package/dist/processors/ini.d.ts +33 -0
- package/dist/processors/ini.d.ts.map +1 -0
- package/dist/processors/ini.js +500 -0
- package/dist/processors/ini.js.map +1 -0
- package/dist/processors/insert.d.ts.map +1 -1
- package/dist/processors/insert.js +338 -15
- package/dist/processors/insert.js.map +1 -1
- package/dist/processors/on.d.ts +4 -0
- package/dist/processors/on.d.ts.map +1 -0
- package/dist/processors/on.js +54 -0
- package/dist/processors/on.js.map +1 -0
- package/dist/ref.d.ts +21 -0
- package/dist/ref.d.ts.map +1 -0
- package/dist/ref.js +65 -0
- package/dist/ref.js.map +1 -0
- package/dist/sources/bitbucket.d.ts.map +1 -1
- package/dist/sources/bitbucket.js +51 -12
- package/dist/sources/bitbucket.js.map +1 -1
- package/dist/sources/git.d.ts.map +1 -1
- package/dist/sources/git.js +54 -6
- package/dist/sources/git.js.map +1 -1
- package/dist/sources/github.d.ts +1 -0
- package/dist/sources/github.d.ts.map +1 -1
- package/dist/sources/github.js +287 -50
- package/dist/sources/github.js.map +1 -1
- package/dist/sources/gitlab.d.ts +1 -0
- package/dist/sources/gitlab.d.ts.map +1 -1
- package/dist/sources/gitlab.js +387 -28
- package/dist/sources/gitlab.js.map +1 -1
- package/dist/sources/index.d.ts +3 -1
- package/dist/sources/index.d.ts.map +1 -1
- package/dist/sources/index.js +206 -44
- package/dist/sources/index.js.map +1 -1
- package/dist/sources/local.d.ts +3 -0
- package/dist/sources/local.d.ts.map +1 -1
- package/dist/sources/local.js +44 -0
- package/dist/sources/local.js.map +1 -1
- package/dist/types.d.ts +71 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/variables-remote.d.ts.map +1 -1
- package/dist/variables-remote.js +88 -7
- package/dist/variables-remote.js.map +1 -1
- package/dist/variables.d.ts +1 -0
- package/dist/variables.d.ts.map +1 -1
- package/dist/variables.js +133 -12
- package/dist/variables.js.map +1 -1
- package/dist/writer.d.ts +18 -0
- package/dist/writer.d.ts.map +1 -1
- package/dist/writer.js +968 -25
- package/dist/writer.js.map +1 -1
- package/package.json +12 -8
package/dist/commands/pull.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
207
|
-
text = (0,
|
|
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(
|
|
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(
|
|
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,
|
|
219
|
-
insertedFragments.set(
|
|
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
|
-
|
|
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,
|
|
358
|
+
? (0, variables_1.resolveBackupPath)(entry.backup, ep, workingDir, vars, config.backup_roots ?? [])
|
|
237
359
|
: undefined;
|
|
238
360
|
writeTargets.push({
|
|
239
|
-
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
//
|
|
591
|
-
//
|
|
592
|
-
|
|
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
|
-
|
|
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
|