@udondan/avanti 0.24.0 → 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 +467 -66
- package/dist/commands/diff.d.ts.map +1 -1
- package/dist/commands/diff.js +75 -12
- 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 +675 -36
- 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 +17 -0
- package/dist/config-writeback.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +244 -3
- package/dist/config.js.map +1 -1
- package/dist/diff.d.ts +19 -0
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +317 -12
- 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.map +1 -1
- package/dist/sources/github.js +188 -51
- package/dist/sources/github.js.map +1 -1
- package/dist/sources/gitlab.d.ts.map +1 -1
- package/dist/sources/gitlab.js +242 -44
- 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 +170 -34
- 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 +38 -2
- 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 +2 -1
- 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 +37 -2
- package/dist/variables.js.map +1 -1
- package/dist/writer.d.ts +17 -0
- package/dist/writer.d.ts.map +1 -1
- package/dist/writer.js +848 -20
- package/dist/writer.js.map +1 -1
- package/package.json +11 -7
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,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
|
-
|
|
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,
|
|
243
365
|
writeInPlace: entry.writeInPlace,
|
|
366
|
+
sudo: entry.sudo,
|
|
244
367
|
});
|
|
245
|
-
|
|
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(
|
|
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;
|
|
@@ -302,6 +444,7 @@ function pullCommand() {
|
|
|
302
444
|
const dateVars = (0, variables_1.buildDateVars)();
|
|
303
445
|
const firstPass = await runFetchLoop(config, workingDir, dateVars, fetchCache, configPath, history);
|
|
304
446
|
let { writeTargets, allDiffs, sourceRecordsByTarget } = firstPass;
|
|
447
|
+
let fileHookContexts = firstPass.fileHookContexts;
|
|
305
448
|
let insertedFragments = firstPass.insertedFragments;
|
|
306
449
|
let skippedPaths = firstPass.skippedPaths;
|
|
307
450
|
let hasUnresolvableSkippedPath = firstPass.hasUnresolvableSkippedPath;
|
|
@@ -321,6 +464,7 @@ function pullCommand() {
|
|
|
321
464
|
let prevSelfContent;
|
|
322
465
|
let currentSelfContent = firstPass.selfContent;
|
|
323
466
|
let currentSelfMode = firstPass.selfMode;
|
|
467
|
+
let currentSelfSudo = firstPass.selfSudo;
|
|
324
468
|
let currentSelfSourceRecords = firstPass.selfSourceRecords;
|
|
325
469
|
let stableConfig;
|
|
326
470
|
while (stableConfig === undefined) {
|
|
@@ -362,6 +506,7 @@ function pullCommand() {
|
|
|
362
506
|
prevSelfContent = currentSelfContent;
|
|
363
507
|
currentSelfContent = next.selfContent;
|
|
364
508
|
currentSelfMode = next.selfMode;
|
|
509
|
+
currentSelfSudo = next.selfSudo;
|
|
365
510
|
currentSelfSourceRecords = next.selfSourceRecords;
|
|
366
511
|
}
|
|
367
512
|
if (stableConfig !== undefined) {
|
|
@@ -386,6 +531,7 @@ function pullCommand() {
|
|
|
386
531
|
}
|
|
387
532
|
writeTargets = second.writeTargets;
|
|
388
533
|
allDiffs = second.allDiffs;
|
|
534
|
+
fileHookContexts = second.fileHookContexts;
|
|
389
535
|
sourceRecordsByTarget = second.sourceRecordsByTarget;
|
|
390
536
|
insertedFragments = second.insertedFragments;
|
|
391
537
|
skippedPaths = second.skippedPaths;
|
|
@@ -409,6 +555,7 @@ function pullCommand() {
|
|
|
409
555
|
targetPath: configPath,
|
|
410
556
|
content: selfBuf,
|
|
411
557
|
mode: currentSelfMode,
|
|
558
|
+
sudo: currentSelfSudo,
|
|
412
559
|
});
|
|
413
560
|
allDiffs.push((0, diff_1.computeDiff)(configPath, selfBuf, currentSelfMode));
|
|
414
561
|
}
|
|
@@ -418,6 +565,10 @@ function pullCommand() {
|
|
|
418
565
|
...writeTargets[existingIdx],
|
|
419
566
|
content: selfBuf,
|
|
420
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,
|
|
421
572
|
};
|
|
422
573
|
allDiffs[existingIdx] = (0, diff_1.computeDiff)(configPath, selfBuf, resolvedSelfMode);
|
|
423
574
|
}
|
|
@@ -434,8 +585,15 @@ function pullCommand() {
|
|
|
434
585
|
}
|
|
435
586
|
// Detect stale files: present in last pull but no longer in current source fetch
|
|
436
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();
|
|
437
592
|
const staleToRestore = [];
|
|
438
593
|
const staleDiffs = [];
|
|
594
|
+
// Parallel array: staleDiffs index for each staleToRestore entry
|
|
595
|
+
const staleRestoreDiffIndices = [];
|
|
596
|
+
let staleHasError = false;
|
|
439
597
|
if (historyAvailable && !hasUnresolvableSkippedPath) {
|
|
440
598
|
const lastFiles = history.getLastPullFiles();
|
|
441
599
|
const currentPaths = new Set(writeTargets.map((t) => t.targetPath));
|
|
@@ -457,22 +615,253 @@ function pullCommand() {
|
|
|
457
615
|
if (meta.existedBeforeAvanti) {
|
|
458
616
|
const original = history.readVersion(ref.absolutePath, 0);
|
|
459
617
|
if (original !== null) {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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.`);
|
|
465
670
|
}
|
|
466
671
|
}
|
|
467
672
|
else {
|
|
468
673
|
staleToDelete.push(ref.absolutePath);
|
|
674
|
+
if (meta.sudo)
|
|
675
|
+
staleDeleteSudo.set(ref.absolutePath, meta.sudo);
|
|
676
|
+
staleDeleteDiffIndex.set(ref.absolutePath, staleDiffs.length);
|
|
469
677
|
staleDiffs.push((0, diff_1.computeDeleteDiff)(ref.absolutePath));
|
|
470
678
|
}
|
|
471
679
|
}
|
|
472
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
|
+
}
|
|
473
860
|
const hasChanges = allDiffs.some((d) => d.hasChanges) ||
|
|
474
861
|
staleDiffs.some((d) => d.hasChanges);
|
|
475
862
|
(0, diff_1.printDiffs)([...allDiffs, ...staleDiffs]);
|
|
863
|
+
if (staleHasError)
|
|
864
|
+
process.exit(2);
|
|
476
865
|
// Show SHA mismatch summary when using --accept-changes
|
|
477
866
|
if (opts.acceptChanges && firstPass.shaErrors.length > 0) {
|
|
478
867
|
console.error('');
|
|
@@ -484,6 +873,26 @@ function pullCommand() {
|
|
|
484
873
|
history.saveInsertedFragment(targetPath, fragment.raw, fragment.processed);
|
|
485
874
|
}
|
|
486
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
|
+
}
|
|
487
896
|
console.log('Nothing to do.');
|
|
488
897
|
process.exit(0);
|
|
489
898
|
}
|
|
@@ -531,6 +940,74 @@ function pullCommand() {
|
|
|
531
940
|
}
|
|
532
941
|
}
|
|
533
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
|
+
}
|
|
534
1011
|
// Stage history versions before atomicWrite so v0 is captured before overwrite
|
|
535
1012
|
const stagedFileRefs = [];
|
|
536
1013
|
if (pullId) {
|
|
@@ -553,7 +1030,36 @@ function pullCommand() {
|
|
|
553
1030
|
acceptedShaLabels.has(r.sourceLabel),
|
|
554
1031
|
}))
|
|
555
1032
|
: undefined;
|
|
556
|
-
|
|
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);
|
|
557
1063
|
stagedFileRefs.push(fileRef);
|
|
558
1064
|
}
|
|
559
1065
|
catch {
|
|
@@ -561,11 +1067,76 @@ function pullCommand() {
|
|
|
561
1067
|
}
|
|
562
1068
|
}
|
|
563
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();
|
|
564
1077
|
try {
|
|
565
|
-
|
|
1078
|
+
// changedTargets and sudoValues already computed above; auth already done.
|
|
566
1079
|
// Content writes go first so that if atomicWrite throws, no permissions
|
|
567
1080
|
// have been changed yet (minimises partial-apply surface).
|
|
568
|
-
(
|
|
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
|
+
}
|
|
569
1140
|
// Mode-only changes: apply chmod directly (POSIX only — mode bits are
|
|
570
1141
|
// not meaningful on Windows so modeChange is never set there).
|
|
571
1142
|
let modeOnlyCount = 0;
|
|
@@ -573,26 +1144,72 @@ function pullCommand() {
|
|
|
573
1144
|
for (let i = 0; i < writeTargets.length; i++) {
|
|
574
1145
|
const d = allDiffs[i];
|
|
575
1146
|
if (d.modeChange && !d.contentChanged) {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
+
}
|
|
582
1171
|
}
|
|
583
1172
|
}
|
|
584
1173
|
}
|
|
585
1174
|
}
|
|
1175
|
+
const deletedCount = effectivelyDeleted.size;
|
|
586
1176
|
const written = changedTargets.length +
|
|
587
|
-
|
|
588
|
-
|
|
1177
|
+
activeStaleRestore.length +
|
|
1178
|
+
deletedCount +
|
|
589
1179
|
modeOnlyCount;
|
|
590
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
|
+
}
|
|
591
1203
|
}
|
|
592
1204
|
catch (err) {
|
|
593
1205
|
console.error(`Write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
594
1206
|
process.exit(2);
|
|
595
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.
|
|
596
1213
|
// Save inserted fragments to history for future idempotency detection
|
|
597
1214
|
if (historyAvailable && insertedFragments.size > 0) {
|
|
598
1215
|
for (const [targetPath, fragment] of insertedFragments) {
|
|
@@ -611,16 +1228,38 @@ function pullCommand() {
|
|
|
611
1228
|
console.warn(`Warning: could not update SHA values in config: ${err instanceof Error ? err.message : String(err)}`);
|
|
612
1229
|
}
|
|
613
1230
|
}
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
|
|
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)) {
|
|
617
1238
|
try {
|
|
618
|
-
|
|
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);
|
|
619
1254
|
}
|
|
620
1255
|
catch {
|
|
621
1256
|
console.warn('Warning: could not save pull history.');
|
|
622
1257
|
}
|
|
623
1258
|
}
|
|
1259
|
+
if (postWriteError !== null) {
|
|
1260
|
+
console.error(postWriteError);
|
|
1261
|
+
process.exit(2);
|
|
1262
|
+
}
|
|
624
1263
|
});
|
|
625
1264
|
}
|
|
626
1265
|
//# sourceMappingURL=pull.js.map
|