@pathbit/agentdevkit 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
2
+ import { chmodSync, closeSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
 
@@ -63,6 +63,7 @@ export function renderClientIgnore(template, settings = {}) {
63
63
 
64
64
  const localStat = file => { try { return lstatSync(file); } catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) return null; throw error; } };
65
65
  const localRead = file => localStat(file) ? readFileSync(file, 'utf8') : null;
66
+ const localMode = file => { const item = localStat(file); return item ? item.mode & 0o777 : null; };
66
67
  const portable = path => path.split(sep).join('/');
67
68
 
68
69
  function canonicalExisting(path) {
@@ -144,6 +145,61 @@ function artifactProbes(root, patterns) {
144
145
  return [...probes];
145
146
  }
146
147
 
148
+ const matchesComponent = (part, pattern) => posix.matchesGlob(`x${part}`, `x${pattern.replace(/[{}()]/g, '\\$&')}`);
149
+
150
+ function matchesParts(parts, pattern) {
151
+ const cache = new Map();
152
+ function match(part, glob) {
153
+ const key = `${part}:${glob}`; if (cache.has(key)) return cache.get(key);
154
+ const expected = pattern[glob];
155
+ const result = glob === pattern.length ? part === parts.length
156
+ : expected === '**' ? match(part, glob + 1) || part < parts.length && match(part + 1, glob)
157
+ : part < parts.length && matchesComponent(parts[part], expected) && match(part + 1, glob + 1);
158
+ cache.set(key, result); return result;
159
+ }
160
+ return match(0, 0);
161
+ }
162
+
163
+ function artifactPath(path, patterns, directory = false) {
164
+ const parts = path.split('/');
165
+ return patterns.some(pattern => {
166
+ const rule = pattern.slice(3).replace(/\/$/, '');
167
+ return parts.some((part, index) => (index < parts.length - 1 || directory || !pattern.endsWith('/'))
168
+ && (rule.includes('/') ? matchesParts(parts.slice(0, index + 1), ['**', ...rule.split('/')])
169
+ : matchesComponent(part, rule)));
170
+ });
171
+ }
172
+
173
+ function visibleArtifactProbes(root, location, patterns) {
174
+ const listed = gitCommand(location.top, ['--literal-pathspecs', '-c', 'core.excludesFile=', 'ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', location.prefix ? `${location.prefix}/` : '.']);
175
+ if (listed.error || listed.status !== 0) throw new Error(listed.error?.message || listed.stderr || 'Falha ao conferir artefatos existentes.');
176
+ const paths = listed.stdout.split('\0').filter(Boolean), prefix = location.prefix ? `${location.prefix}/` : '';
177
+ const probes = new Set(paths.filter(path => path.startsWith(prefix)).map(path => path.slice(prefix.length)).filter(path => artifactPath(path, patterns)));
178
+ const ignores = new Set(paths.filter(path => path.startsWith(prefix) && (path === '.gitignore' || path.endsWith('/.gitignore'))));
179
+ let ancestor = root;
180
+ while (true) {
181
+ ignores.add(portable(relative(location.top, join(ancestor, '.gitignore'))));
182
+ if (ancestor === location.top) break;
183
+ ancestor = dirname(ancestor);
184
+ }
185
+ for (const path of ignores) {
186
+ const file = join(location.top, path), item = localStat(file);
187
+ if (!item?.isFile() || item.isSymbolicLink()) continue;
188
+ // Git resolves precedence; these literals only add witnesses for named exceptions.
189
+ for (const line of readFileSync(checkedLocalPath(file), 'utf8').split(/\r?\n/)) {
190
+ if (!line.startsWith('!') || line.length === 1) continue;
191
+ const rule = line.slice(1).replace(/(?<!\\) +$/, ''), directory = rule.endsWith('/') || rule.endsWith('/**');
192
+ const literal = rule.replace(/(?:\/\*\*)+\/?$/, '').replace(/\/$/, '').replace(/^\//, '');
193
+ if (/(^|[^\\])[*?\[]/.test(literal)) continue;
194
+ const ruleBase = !rule.startsWith('/') && !literal.includes('/') && relative(dirname(file), root).split(sep)[0] !== '..' ? root : dirname(file);
195
+ const candidate = portable(relative(root, resolve(ruleBase, literal.replace(/\\(.)/g, '$1'))));
196
+ if (!candidate || candidate === '..' || candidate.startsWith('../') || !artifactPath(candidate, patterns, directory)) continue;
197
+ probes.add(checkedOutput(directory ? `${candidate}/.agentdevkit-artifact-probe` : candidate));
198
+ }
199
+ }
200
+ return [...probes];
201
+ }
202
+
147
203
  function localIgnoreSource(location, source) {
148
204
  const path = resolve(location.top, source);
149
205
  if (path === location.exclude) return true;
@@ -204,9 +260,9 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
204
260
  const body = fixed.split(/\r?\n/).map(line => location?.prefix && line.startsWith('/') ? `/${escapePattern(location.prefix)}${line}` : line).join('\n');
205
261
  const target = location?.exclude ?? checkedLocalPath(join(root, '.gitignore'));
206
262
  const before = localRead(target), after = localBlock(before, first, last, body);
207
- const edits = before === after ? [] : [{ path: target, before, after }];
263
+ const edits = before === after ? [] : [{ path: target, before, after, beforeMode: localMode(target) }];
208
264
  const probes = [...paths, ...rootProbes(root, roots)];
209
- const artifactPaths = artifactProbes(root, artifacts);
265
+ const artifactPaths = [...new Set([...artifactProbes(root, artifacts), ...(location ? visibleArtifactProbes(root, location, artifacts) : [])])];
210
266
  const wanted = new Set(probes.map(path => location?.prefix ? `${location.prefix}/${path}` : path));
211
267
  if (location && !before?.includes(first) && !localRead(join(root, '.gitignore'))?.includes(first)) {
212
268
  const ignored = ignoreMatches(location, [...probes, ...artifactPaths]);
@@ -215,10 +271,10 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
215
271
  if (location) {
216
272
  const fallback = checkedLocalPath(join(root, '.gitignore'));
217
273
  const old = localRead(fallback), next = localBlock(old, first, last, null);
218
- if (old !== null && old !== next) edits.push({ path: fallback, before: old, after: next });
274
+ if (old !== null && old !== next) edits.push({ path: fallback, before: old, after: next, beforeMode: localMode(fallback) });
219
275
  }
220
276
  const tracked = location ? gitCommand(location.top, ['ls-files', '-z']).stdout.split('\0').filter(path => wanted.has(path)).map(path => location.prefix ? path.slice(location.prefix.length + 1) : path) : [];
221
- return { root, status: location ? 'git' : 'fallback', location, paths, roots, artifactProbes: artifactPaths, edits, trackedOutputs: tracked, first, last };
277
+ return { root, status: location ? 'git' : 'fallback', location, paths, roots, artifactPatterns: artifacts, artifactProbes: artifactPaths, edits, trackedOutputs: tracked, first, last };
222
278
  }
223
279
 
224
280
  export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
@@ -235,7 +291,8 @@ export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
235
291
  return { ...item, path };
236
292
  });
237
293
  if (plan.location && (plan.paths.length || plan.artifactProbes?.length)) {
238
- const probes = [...plan.paths, ...rootProbes(plan.root, plan.roots || []), ...(plan.artifactProbes || [])];
294
+ const probes = [...new Set([...plan.paths, ...rootProbes(plan.root, plan.roots || []), ...(plan.artifactProbes || []),
295
+ ...(plan.artifactPatterns ? visibleArtifactProbes(plan.root, plan.location, plan.artifactPatterns) : [])])];
239
296
  const { paths: relativePaths, matches } = ignoreMatches(plan.location, probes);
240
297
  for (let index = 0; index < relativePaths.length; index++) {
241
298
  const match = matches.get(relativePaths[index]);
@@ -252,10 +309,21 @@ export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
252
309
  export function assertLocalIgnoreUnchanged(plan) {
253
310
  for (const edit of plan.edits) {
254
311
  checkedLocalPath(edit.path);
255
- if (localRead(edit.path) !== edit.before) throw new Error(`Exclusao alterada apos planejar: ${edit.path}`);
312
+ if (localRead(edit.path) !== edit.before || Object.hasOwn(edit, 'beforeMode') && localMode(edit.path) !== edit.beforeMode) throw new Error(`Exclusao alterada apos planejar: ${edit.path}`);
256
313
  }
257
314
  }
258
315
 
316
+ function atomicLocalWrite(path, content, mode, published) {
317
+ const temporary = `${path}.agentdevkit-${randomUUID()}`;
318
+ try {
319
+ writeFileSync(temporary, content, { flag: 'wx' });
320
+ if (mode !== null && mode !== undefined) chmodSync(temporary, mode);
321
+ const writtenMode = localMode(temporary);
322
+ renameSync(temporary, path);
323
+ published?.(writtenMode);
324
+ } finally { rmSync(temporary, { force: true }); }
325
+ }
326
+
259
327
  export function applyLocalIgnore(plan) {
260
328
  assertLocalIgnoreUnchanged(plan);
261
329
  const before = checkLocalIgnore(plan);
@@ -264,18 +332,19 @@ export function applyLocalIgnore(plan) {
264
332
  try {
265
333
  for (const edit of plan.edits) {
266
334
  mkdirSync(dirname(edit.path), { recursive: true });
267
- const temporary = `${edit.path}.agentdevkit-${randomUUID()}`;
268
- try { writeFileSync(temporary, edit.after, { flag: 'wx' }); renameSync(temporary, edit.path); }
269
- finally { rmSync(temporary, { force: true }); }
270
- written.push(edit);
335
+ atomicLocalWrite(edit.path, edit.after, edit.beforeMode, mode => written.push({ edit, mode }));
271
336
  }
272
337
  const verified = checkLocalIgnore({ ...plan, edits: [], trackedOutputs: [] });
273
338
  if (verified.missing.length) throw new Error(`Regras humanas reabrem derivados locais: ${verified.missing.join(', ')}`);
274
339
  } catch (error) {
275
- for (const edit of written.reverse()) {
276
- if (localRead(edit.path) !== edit.after) continue;
277
- if (edit.before === null) rmSync(edit.path, { force: true }); else writeFileSync(edit.path, edit.before);
340
+ const rollbackErrors = [];
341
+ for (const { edit, mode } of written.reverse()) {
342
+ try {
343
+ if (localRead(edit.path) !== edit.after || localMode(edit.path) !== mode) continue;
344
+ if (edit.before === null) rmSync(edit.path, { force: true }); else atomicLocalWrite(edit.path, edit.before, edit.beforeMode);
345
+ } catch (failure) { rollbackErrors.push(failure); }
278
346
  }
347
+ if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], `${error.message}; rollback incompleto: ${rollbackErrors.map(failure => failure.message).join('; ')}`, { cause: error });
279
348
  throw error;
280
349
  }
281
350
  return { status: plan.status, changed: plan.edits.length, trackedOutputs: plan.trackedOutputs };
@@ -101,6 +101,14 @@ function sourcePath(root, name) {
101
101
  return file;
102
102
  }
103
103
 
104
+ function storagePath(root, name, kind) {
105
+ const file = sourcePath(root, name);
106
+ let item;
107
+ try { item = lstatSync(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
108
+ if (item && !(kind === 'directory' ? item.isDirectory() : item.isFile())) fail(`Caminho local exige ${kind}: ${name}`);
109
+ return file;
110
+ }
111
+
104
112
  export function loadCatalog(root = projectRoot) {
105
113
  root = realpathSync(root);
106
114
  const home = sourcePath(root, '.pbai4devs/tools'); const catalog = Object.create(null);
@@ -272,15 +280,21 @@ export class ToolManager {
272
280
  return { mode: 'setup', selection: selected, platform: this.target, node: process.version, nodePath: process.execPath, npm, results: entries, summary, status: summary.failed ? 'failed' : summary.passed ? 'passed' : 'not-applicable', exitCode: summary.failed ? 1 : 0 };
273
281
  }
274
282
  local(name) { this.entry(name); return join(this.home, name, '.local'); }
283
+ storage(name) {
284
+ const local = this.local(name), tool = join(this.home, name);
285
+ for (const path of [this.home, tool, local, join(local, 'versions'), join(tool, 'versions'), join(this.home, '.cache'), join(this.home, '.cache/npm'), join(this.home, 'npm-cache')]) storagePath(this.root, path, 'directory');
286
+ for (const path of [join(local, 'CURRENT.json'), join(tool, 'CURRENT.json')]) storagePath(this.root, path, 'file');
287
+ return { local, tool };
288
+ }
275
289
  current(name) {
276
- this.entry(name);
277
- const local = this.local(name); const legacy = join(this.home, name);
290
+ const { local, tool: legacy } = this.storage(name);
278
291
  const base = existsSync(join(local, 'CURRENT.json')) ? local : legacy;
279
292
  const file = join(base, 'CURRENT.json');
280
293
  if (!existsSync(file)) fail(`Ferramenta ausente: ${name}; execute install ${name}.`);
281
294
  const info = json(file);
282
295
  if (info.name !== name || info.platform !== this.target || !versionPattern.test(info.version || '')) fail(`Instalacao incompativel: ${name}`);
283
- return { ...info, layout: base === local ? 'local' : 'legacy', prefix: join(base, 'versions', `${info.version}-${this.target}`) };
296
+ const prefix = storagePath(this.root, join(base, 'versions', `${info.version}-${this.target}`), 'directory');
297
+ return { ...info, layout: base === local ? 'local' : 'legacy', prefix };
284
298
  }
285
299
  check(name) {
286
300
  const entry = this.entry(name); const runtime = this.runtime(name); const info = this.current(name);
@@ -306,7 +320,7 @@ export class ToolManager {
306
320
  for (const legacy of [name, `${name}.cmd`, `${name}.ps1`, `${name}.sh`]) rmSync(join(bin, legacy), { force: true });
307
321
  }
308
322
  migrate(name) {
309
- const local = this.local(name); const tool = join(this.home, name);
323
+ const { local, tool } = this.storage(name);
310
324
  const legacyState = join(tool, 'CURRENT.json'); const legacyVersions = join(tool, 'versions');
311
325
  if (existsSync(legacyState)) {
312
326
  if (existsSync(join(local, 'CURRENT.json')) || (existsSync(join(local, 'versions')) && readdirSync(join(local, 'versions')).length)) fail(`Layouts local e antigo coexistem: ${name}; revise antes de migrar.`);
@@ -318,15 +332,15 @@ export class ToolManager {
318
332
  }
319
333
  const oldCache = join(this.home, 'npm-cache');
320
334
  if (existsSync(oldCache)) {
321
- const cache = join(this.home, '.cache'); mkdirSync(cache, { recursive: true });
322
- try { renameSync(oldCache, join(cache, existsSync(join(cache, 'npm')) ? `npm-legacy-${process.pid}-${Date.now()}` : 'npm')); }
335
+ const cache = storagePath(this.root, join(this.home, '.cache'), 'directory'); mkdirSync(cache, { recursive: true });
336
+ const destination = storagePath(this.root, join(cache, existsSync(join(cache, 'npm')) ? `npm-legacy-${randomUUID()}` : 'npm'), 'directory');
337
+ try { renameSync(oldCache, destination); }
323
338
  catch (error) { if (error.code !== 'ENOENT' || existsSync(oldCache)) throw error; }
324
339
  }
325
340
  }
326
341
  async withLock(name, action) {
327
- const local = this.local(name);
328
- for (const directory of [this.home, join(this.home, name), local]) if (existsSync(directory) && lstatSync(directory).isSymbolicLink()) fail(`Diretorio local nao pode ser um link: ${directory}`);
329
- mkdirSync(local, { recursive: true }); const lock = join(local, '.update-lock');
342
+ const { local } = this.storage(name);
343
+ mkdirSync(local, { recursive: true }); const lock = storagePath(this.root, join(local, '.update-lock'), 'directory');
330
344
  try { mkdirSync(lock); } catch (error) {
331
345
  if (error.code === 'EEXIST') {
332
346
  const status = this.lockStatus(name);
@@ -334,8 +348,8 @@ export class ToolManager {
334
348
  }
335
349
  throw error;
336
350
  }
337
- try { writeFileSync(join(lock, 'owner.json'), JSON.stringify({ pid: process.pid, hostname: hostname(), startedAt: new Date().toISOString() })); return await action(); }
338
- finally { rmSync(lock, { recursive: true, force: true }); }
351
+ try { writeFileSync(storagePath(this.root, join(lock, 'owner.json'), 'file'), JSON.stringify({ pid: process.pid, hostname: hostname(), startedAt: new Date().toISOString() }), { flag: 'wx' }); return await action(); }
352
+ finally { rmSync(storagePath(this.root, lock, 'directory'), { recursive: true, force: true }); }
339
353
  }
340
354
  lockStatus(name) {
341
355
  const lock = sourcePath(this.root, join(this.local(name), '.update-lock'));
@@ -380,29 +394,36 @@ export class ToolManager {
380
394
  return this.updateLocked(name);
381
395
  }); }
382
396
  async update(name) { this.preflight([name]); return this.withLock(name, async () => { this.migrate(name); return this.updateLocked(name); }); }
383
- commitState(name, temporary) { renameSync(temporary, join(this.local(name), 'CURRENT.json')); }
397
+ commitState(name, temporary) {
398
+ const { local } = this.storage(name);
399
+ renameSync(storagePath(this.root, temporary, 'file'), storagePath(this.root, join(local, 'CURRENT.json'), 'file'));
400
+ }
384
401
  async updateLocked(name) {
385
402
  const entry = this.entry(name); const runtime = this.runtime(name); const release = validateRelease(name, entry, await this.fetchRelease(name, entry));
403
+ const { local } = this.storage(name);
404
+ const target = storagePath(this.root, join(local, 'versions', `${release.version}-${this.target}`), 'directory');
386
405
  let installed;
387
406
  try { installed = this.check(name); } catch { /* Ausente ou quebrado: reparar em staging. */ }
388
407
  if (installed?.version === release.version) { this.launchers(name); return { ...installed, changed: false }; }
389
- const local = this.local(name); mkdirSync(join(local, 'versions'), { recursive: true });
390
- const stage = mkdtempSync(join(local, '.stage-')); const target = join(local, 'versions', `${release.version}-${this.target}`);
391
- const temporary = join(local, `CURRENT.${process.pid}.${Date.now()}.json`); let previous; let moved = false;
408
+ mkdirSync(storagePath(this.root, join(local, 'versions'), 'directory'), { recursive: true });
409
+ const stage = mkdtempSync(join(local, '.stage-'));
410
+ const temporary = storagePath(this.root, join(local, `CURRENT.${randomUUID()}.json`), 'file'); let previous; let moved = false;
392
411
  try {
393
412
  this.installPackage(stage, entry.download.package, release.version, entry, runtime);
413
+ this.storage(name); storagePath(this.root, stage, 'directory');
394
414
  const pkg = json(join(stage, 'node_modules', entry.download.package, 'package.json')); const lock = json(join(stage, 'package-lock.json'));
395
415
  if (pkg.name !== entry.download.package || pkg.version !== release.version || lock.packages?.[`node_modules/${entry.download.package}`]?.integrity !== release.dist.integrity) fail('Pacote instalado nao corresponde ao registro.');
396
416
  prepareExecutables(stage, entry, this.target);
397
417
  execute(stage, entry, entry.probe, true, entry.probeInput, toolEnvironment(entry, local, runtime), runtime);
418
+ this.storage(name); storagePath(this.root, target, 'directory');
398
419
  const info = { name, package: entry.download.package, version: release.version, platform: this.target, node: runtime.version, nodePath: runtime.file, source: entry.source, tarball: release.dist.tarball, integrity: release.dist.integrity, checkedAt: new Date().toISOString() };
399
- writeFileSync(temporary, `${JSON.stringify(info, null, 2)}\n`);
420
+ writeFileSync(storagePath(this.root, temporary, 'file'), `${JSON.stringify(info, null, 2)}\n`, { flag: 'wx' });
400
421
  this.launchers(name);
401
- if (existsSync(target)) { previous = `${target}.previous-${process.pid}-${Date.now()}`; renameSync(target, previous); }
402
- try { renameSync(stage, target); moved = true; this.commitState(name, temporary); }
403
- catch (error) { if (moved) rmSync(target, { recursive: true, force: true }); if (previous) renameSync(previous, target); throw error; }
422
+ if (existsSync(target)) { previous = storagePath(this.root, `${target}.previous-${randomUUID()}`, 'directory'); renameSync(storagePath(this.root, target, 'directory'), previous); }
423
+ try { renameSync(storagePath(this.root, stage, 'directory'), storagePath(this.root, target, 'directory')); moved = true; this.commitState(name, temporary); }
424
+ catch (error) { if (moved) rmSync(storagePath(this.root, target, 'directory'), { recursive: true, force: true }); if (previous) renameSync(storagePath(this.root, previous, 'directory'), storagePath(this.root, target, 'directory')); throw error; }
404
425
  return { ...info, layout: 'local', prefix: target, changed: true };
405
- } finally { rmSync(stage, { recursive: true, force: true }); rmSync(temporary, { force: true }); }
426
+ } finally { rmSync(storagePath(this.root, stage, 'directory'), { recursive: true, force: true }); rmSync(storagePath(this.root, temporary, 'file'), { force: true }); }
406
427
  }
407
428
  run(name, args) { const entry = this.entry(name); const runtime = this.runtime(name); return execute(this.current(name).prefix, entry, args, false, undefined, toolEnvironment(entry, this.local(name), runtime), runtime); }
408
429
  }
@@ -232,6 +232,87 @@ test('falha ao gravar CURRENT restaura o target anterior e o pacote em uso', asy
232
232
  assert.equal(manager.check('test-tool').version, '1.0.0'); assert.ok(!existsSync(join(local, '.update-lock')));
233
233
  });
234
234
 
235
+ test('versions simbolico existente ou quebrado e recusado antes de baixar ou escrever', async t => {
236
+ for (const broken of [false, true]) await t.test(broken ? 'broken' : 'existing', async subtest => {
237
+ const { root, manager, state, local } = fixture(subtest);
238
+ const outside = join(root, 'outside'); mkdirSync(outside);
239
+ writeFileSync(join(outside, 'keep.txt'), 'preservado'); mkdirSync(local);
240
+ if (!createSymlink(subtest, broken ? join(outside, 'missing') : outside, join(local, 'versions'), process.platform === 'win32' ? 'junction' : 'dir')) return;
241
+ for (const action of ['install', 'update']) await assert.rejects(manager[action]('test-tool'), /nao pode ser um link/);
242
+ assert.equal(state.fetches, 0); assert.equal(state.installs, 0);
243
+ assert.deepEqual(readdirSync(outside), ['keep.txt']);
244
+ assert.equal(readFileSync(join(outside, 'keep.txt'), 'utf8'), 'preservado');
245
+ assert.equal(existsSync(join(local, 'CURRENT.json')), false);
246
+ assert.equal(existsSync(join(local, '.update-lock')), false);
247
+ });
248
+ });
249
+
250
+ test('recibo CURRENT e destino de versao simbolicos nao sao seguidos nem substituidos', async t => {
251
+ for (const kind of ['current', 'target', 'broken-target']) await t.test(kind, async subtest => {
252
+ const { root, manager, state, local } = fixture(subtest); await manager.install('test-tool');
253
+ const receipt = readFileSync(join(local, 'CURRENT.json'));
254
+ const outside = join(root, 'outside'); mkdirSync(outside); writeFileSync(join(outside, 'keep.txt'), 'preservado');
255
+ if (kind === 'current') {
256
+ writeFileSync(join(outside, 'CURRENT.json'), receipt); rmSync(join(local, 'CURRENT.json'));
257
+ if (!createSymlink(subtest, join(outside, 'CURRENT.json'), join(local, 'CURRENT.json'))) return;
258
+ assert.throws(() => manager.check('test-tool'), /nao pode ser um link/);
259
+ } else {
260
+ state.version = '2.0.0';
261
+ if (!createSymlink(subtest, kind === 'target' ? outside : join(outside, 'missing'), join(local, 'versions', `2.0.0-${platformKey()}`), process.platform === 'win32' ? 'junction' : 'dir')) return;
262
+ }
263
+ const before = readdirSync(outside), installs = state.installs;
264
+ await assert.rejects(manager.update('test-tool'), /nao pode ser um link/);
265
+ assert.equal(state.installs, installs); assert.deepEqual(readdirSync(outside), before);
266
+ assert.equal(readFileSync(join(outside, 'keep.txt'), 'utf8'), 'preservado');
267
+ assert.deepEqual(readFileSync(kind === 'current' ? join(outside, 'CURRENT.json') : join(local, 'CURRENT.json')), receipt);
268
+ });
269
+ });
270
+
271
+ test('troca de versions durante fetch ou install nao escapa da arvore local', async t => {
272
+ for (const phase of ['fetch', 'install']) await t.test(phase, async subtest => {
273
+ const { root, manager, state, local } = fixture(subtest); await manager.install('test-tool');
274
+ const receipt = readFileSync(join(local, 'CURRENT.json')); state.version = '2.0.0';
275
+ const outside = join(root, 'outside'); mkdirSync(outside);
276
+ const probe = join(local, 'symlink-probe');
277
+ if (!createSymlink(subtest, outside, probe, process.platform === 'win32' ? 'junction' : 'dir')) return;
278
+ unlinkSync(probe);
279
+ const versions = join(local, 'versions'), kept = join(local, 'versions-kept'); let linked = false;
280
+ const replace = () => {
281
+ renameSync(versions, kept);
282
+ symlinkSync(outside, versions, process.platform === 'win32' ? 'junction' : 'dir'); linked = true;
283
+ };
284
+ if (phase === 'fetch') manager.fetchRelease = async () => { replace(); return release('2.0.0'); };
285
+ else manager.installPackage = (prefix, name, version) => { packageFiles(prefix, name, version); replace(); };
286
+ try {
287
+ await assert.rejects(manager.update('test-tool'), /nao pode ser um link/);
288
+ assert.deepEqual(readdirSync(outside), []); assert.deepEqual(readFileSync(join(local, 'CURRENT.json')), receipt);
289
+ } finally { if (linked) { unlinkSync(versions); renameSync(kept, versions); } }
290
+ assert.equal(manager.check('test-tool').version, '1.0.0');
291
+ assert.equal(existsSync(join(local, '.update-lock')), false);
292
+ });
293
+ });
294
+
295
+ test('migracao recusa links de versions antigo e cache compartilhado sem mover dados', async t => {
296
+ for (const kind of ['legacy-versions', 'cache']) await t.test(kind, async subtest => {
297
+ const { root, manager, state } = fixture(subtest);
298
+ const home = join(root, '.pbai4devs/tools'), legacy = join(home, 'test-tool');
299
+ const outside = join(root, 'outside'); mkdirSync(outside); writeFileSync(join(outside, 'keep.txt'), 'preservado');
300
+ if (kind === 'legacy-versions') {
301
+ writeFileSync(join(legacy, 'CURRENT.json'), JSON.stringify({ name: 'test-tool', version: '1.0.0', platform: platformKey() }));
302
+ if (!createSymlink(subtest, outside, join(legacy, 'versions'), process.platform === 'win32' ? 'junction' : 'dir')) return;
303
+ } else {
304
+ mkdirSync(join(home, 'npm-cache')); writeFileSync(join(home, 'npm-cache/keep.txt'), 'cache antigo');
305
+ if (!createSymlink(subtest, outside, join(home, '.cache'), process.platform === 'win32' ? 'junction' : 'dir')) return;
306
+ }
307
+ await assert.rejects(manager.install('test-tool'), /nao pode ser um link/);
308
+ assert.equal(state.fetches, 0); assert.equal(state.installs, 0);
309
+ assert.deepEqual(readdirSync(outside), ['keep.txt']);
310
+ assert.equal(readFileSync(join(outside, 'keep.txt'), 'utf8'), 'preservado');
311
+ if (kind === 'legacy-versions') assert.ok(existsSync(join(legacy, 'CURRENT.json')));
312
+ else assert.equal(readFileSync(join(home, 'npm-cache/keep.txt'), 'utf8'), 'cache antigo');
313
+ });
314
+ });
315
+
235
316
  test('duas atualizacoes nao disputam a mesma ferramenta nem apagam lock alheio', async t => {
236
317
  const { manager, state, local } = fixture(t); let enter; let proceed;
237
318
  const entered = new Promise(resolve => { enter = resolve; });
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  // Planeja em memória; somente os caminhos gerenciados pelo manifesto são alterados.
3
- import { cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
4
4
  import { dirname, join, posix, resolve } from 'node:path';
5
5
  import { randomUUID, createHash } from 'node:crypto';
6
6
  import { requireNode, projectRoot, isMain } from '../lib/runtime.mjs';
7
7
  import { buildCatalog } from '../lib/catalog.mjs';
8
8
  import { resourcePath } from '../lib/resource-path.mjs';
9
9
  import { adapterManifest, resolveAdapters } from '../lib/adapters.mjs';
10
- import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
10
+ import { mergeGitignore, validateIgnoreClients, renderClientIgnore, isGeneratedOutput, planLocalIgnore, applyLocalIgnore, checkLocalIgnore, assertLocalIgnoreUnchanged, ignoreBegin, ignoreEnd } from '../lib/gitignore.mjs';
11
11
 
12
12
  const marker = '<!-- gerado: sincronizar-agentes -->';
13
13
  const tomlMarker = '# gerado: sincronizar-agentes';
@@ -113,9 +113,13 @@ export function synchronize({ root = projectRoot, check = false, force = false,
113
113
  }
114
114
  const referenceFile = value => {
115
115
  const info = stat(safe(value));
116
- return Boolean(info?.isFile() && info.size <= 1024 * 1024 && isReference(read(absolute(value))));
116
+ if (!info?.isFile() || info.size > 1024 * 1024) return false;
117
+ const bytes = readFileSync(absolute(value)), text = normalize(bytes.toString('utf8'));
118
+ const recorded = installed?.files?.[value] === createHash('sha256').update(bytes).digest('hex');
119
+ const marked = text.split('\n').includes(marker) || text.startsWith(tomlMarker + '\n');
120
+ return isReference(text) && (marked || recorded);
117
121
  };
118
- const onlyReferences = value => scan(value).every(file => /\.(md|mdc|toml)$/.test(file) && referenceFile(file));
122
+ const onlyReferences = value => { const files = scan(value); return files.length > 0 && files.every(file => /\.(md|mdc|toml)$/.test(file) && referenceFile(file)); };
119
123
  const protectedPath = value => /^(workflows(?:\/|$)|settings\.json$|extensions\.json$)/.test(value.slice(value.indexOf('/') + 1).toLowerCase());
120
124
  const agents = new Map();
121
125
  const formats = new Map();
@@ -237,6 +241,7 @@ export function synchronize({ root = projectRoot, check = false, force = false,
237
241
  }
238
242
  function cleanScope(value) {
239
243
  if (!stat(safe(value))) return;
244
+ if (stat(safe(value)).isDirectory() && !scan(value).length) return;
240
245
  if (force && value.split('/')[1] === 'external-skills') planRemove(value, true);
241
246
  else if (onlyReferences(value)) planRemove(value);
242
247
  else if (force) planRemove(value, true);
@@ -410,16 +415,72 @@ export function synchronize({ root = projectRoot, check = false, force = false,
410
415
  const removals = topPaths(remove);
411
416
  const backups = topPaths(backup);
412
417
  const rootEntryChanges = [...rootEntries].filter(([name, content]) => !stat(absolute(name)) || readFileSync(absolute(name), 'utf8') !== content);
418
+ const omittedRows = new Set([...legacy, ...missing.map(native => native.index)]);
419
+ const finalConfig = rows.filter((row, index) => !omittedRows.has(index)).join('\n').replace(/\n*$/, '\n');
420
+ const configChanged = !configExists || configText !== finalConfig;
421
+ function capture(value) {
422
+ const file = safe(value), info = stat(file);
423
+ if (!info) return null;
424
+ if (info.isFile()) return { mode: info.mode & 0o777, data: readFileSync(file) };
425
+ if (!info.isDirectory()) fail(`Tipo de arquivo nao suportado: ${value}`);
426
+ return { mode: info.mode & 0o777, children: Object.fromEntries(readdirSync(file).sort(compare).map(name => [name, capture(`${value}/${name}`)])) };
427
+ }
428
+ function fingerprint(value) {
429
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
430
+ }
431
+ const transactionRoots = topPaths([...removals, ...changed, ...registries.keys(), ...ignoreFiles.keys(), ...rootEntryChanges.map(([name]) => name), ...(configChanged ? [config] : [])]);
432
+ const originals = new Map(transactionRoots.map(value => [value, capture(value)]));
433
+ const currentHashes = new Map([...originals].map(([value, content]) => [value, fingerprint(content)]));
434
+ const originalDirectories = new Set();
435
+ for (const value of transactionRoots) {
436
+ let parent = posix.dirname(value);
437
+ while (parent !== '.') { if (stat(safe(parent))?.isDirectory()) originalDirectories.add(parent); parent = posix.dirname(parent); }
438
+ }
439
+ const touched = new Set(), createdDirectories = new Set(), restoredDirectories = new Set();
440
+ const prunedDirectories = new Map();
441
+ let restoring = false;
413
442
  const summary = { changed: changed.length + rootEntryChanges.length, stale: removals.length, conflicts: conflicts.size, missing: missing.length, legacy: legacy.length, registries: registries.size, gitignores: ignoreFiles.size, manifests: configExists ? 0 : 1, localIgnores: localStatus.changed + localStatus.missing.length + localStatus.trackedOutputs.length };
414
443
  const dirty = Object.values(summary).some(Boolean);
415
444
  output(`${summary.changed} desatualizadas; ${summary.stale} obsoletas; ${summary.conflicts} conflitos; ${summary.missing} origens nativas ausentes; ${summary.legacy} documentos antigos; ${summary.registries} indices derivados; ${summary.gitignores} gitignores; ${summary.localIgnores} exclusoes locais.`);
416
445
  if (check) return { ...summary, ownedOutputs, exitCode: dirty ? 1 : 0 };
417
446
  if (conflicts.size) fail(`Use --force para reconstruir somente os caminhos gerenciados: ${sorted(conflicts).join(', ')}`);
447
+ if (localStatus.missing.length) fail(`Regras humanas reabrem derivados locais: ${localStatus.missing.join(', ')}`);
448
+ if (localPlan) assertLocalIgnoreUnchanged(localPlan);
449
+ function ensureDirectory(value) {
450
+ let parent = posix.dirname(value);
451
+ const missing = [];
452
+ while (parent !== '.' && !stat(safe(parent))) { missing.push(parent); parent = posix.dirname(parent); }
453
+ for (const directory of missing.reverse()) {
454
+ mkdirSync(safe(directory)); createdDirectories.add(directory);
455
+ if (restoring) restoredDirectories.add(directory);
456
+ }
457
+ }
458
+ function mutate(value, action, text) {
459
+ const boundary = transactionRoots.find(root => value === root || value.startsWith(root + '/'));
460
+ if (!boundary || fingerprint(capture(boundary)) !== currentHashes.get(boundary)) fail(`Arquivo mudou apos planejar: ${value}`);
461
+ const beforeHash = currentHashes.get(boundary);
462
+ touched.add(boundary);
463
+ let failure;
464
+ try { action(); } catch (error) { failure = error; }
465
+ let afterHash;
466
+ try { afterHash = fingerprint(capture(boundary)); }
467
+ catch (error) { currentHashes.set(boundary, 'conteudo-concorrente'); throw failure || error; }
468
+ const matches = text === undefined || (text === null ? !stat(safe(value)) : stat(safe(value))?.isFile() && readFileSync(absolute(value)).equals(Buffer.from(text)));
469
+ if (!matches && afterHash !== beforeHash) {
470
+ currentHashes.set(boundary, 'conteudo-concorrente');
471
+ if (failure) throw failure;
472
+ fail(`Mudanca concorrente preservada: ${value}`);
473
+ }
474
+ currentHashes.set(boundary, afterHash);
475
+ if (failure) throw failure;
476
+ if (!matches) fail(`Arquivo nao corresponde a gravacao planejada: ${value}`);
477
+ }
418
478
  function atomic(value, text) {
419
479
  const target = safe(value);
420
- mkdirSync(dirname(target), { recursive: true });
480
+ const mode = stat(target)?.isFile() ? stat(target).mode & 0o777 : undefined;
481
+ ensureDirectory(value);
421
482
  const temporary = `${target}.sync.${randomUUID()}`;
422
- try { writeFileSync(temporary, text, { flag: 'wx' }); renameSync(temporary, target); }
483
+ try { writeFileSync(temporary, text, { flag: 'wx' }); if (mode !== undefined) chmodSync(temporary, mode); renameSync(temporary, target); }
423
484
  finally { rmSync(temporary, { force: true }); }
424
485
  }
425
486
  let backupRoot;
@@ -433,21 +494,60 @@ export function synchronize({ root = projectRoot, check = false, force = false,
433
494
  }
434
495
  output(`Backup local: ${posix.join('.agent-tests', backupRoot.slice(backupRoot.lastIndexOf('/') + 1).split('\\').at(-1))}`);
435
496
  }
436
- for (const value of removals) rmSync(safe(value), { recursive: true, force: true });
437
- for (const value of changed) atomic(value, expected.get(value));
438
- for (const [value, text] of registries) atomic(value, text);
439
- for (const [value, text] of ignoreFiles) atomic(value, text);
440
- for (const [value, text] of rootEntryChanges) atomic(value, text);
441
- if (localPlan) applyLocalIgnore(localPlan);
442
- function pruneEmpty(value) {
443
- if (!stat(safe(value))?.isDirectory()) return;
444
- for (const name of readdirSync(absolute(value))) if (stat(safe(`${value}/${name}`))?.isDirectory()) pruneEmpty(`${value}/${name}`);
445
- if (!readdirSync(absolute(value)).length) rmdirSync(absolute(value));
497
+ function restore(value, content) {
498
+ rmSync(safe(value), { recursive: true, force: true });
499
+ if (!content) return;
500
+ ensureDirectory(value);
501
+ if (content.data) writeFileSync(safe(value), content.data);
502
+ else {
503
+ mkdirSync(safe(value));
504
+ for (const [name, child] of Object.entries(content.children)) restore(`${value}/${name}`, child);
505
+ }
506
+ chmodSync(safe(value), content.mode);
507
+ }
508
+ try {
509
+ if (localPlan) assertLocalIgnoreUnchanged(localPlan);
510
+ for (const value of removals) mutate(value, () => rmSync(safe(value), { recursive: true, force: true }), null);
511
+ for (const value of changed) mutate(value, () => atomic(value, expected.get(value)), expected.get(value));
512
+ for (const [value, text] of registries) mutate(value, () => atomic(value, text), text);
513
+ for (const [value, text] of ignoreFiles) mutate(value, () => atomic(value, text), text);
514
+ for (const [value, text] of rootEntryChanges) mutate(value, () => atomic(value, text), text);
515
+ if (configChanged) mutate(config, () => atomic(config, finalConfig), finalConfig);
516
+ // Só os ancestrais de remoções conhecidas são candidatos à limpeza.
517
+ for (const value of removals) {
518
+ let parent = posix.dirname(value);
519
+ const boundary = value.split('/').slice(0, 2).join('/');
520
+ while (parent.startsWith(boundary + '/') || parent === boundary) {
521
+ const info = stat(safe(parent));
522
+ if (!info?.isDirectory() || readdirSync(absolute(parent)).length) break;
523
+ rmdirSync(safe(parent)); prunedDirectories.set(parent, info.mode & 0o777); parent = posix.dirname(parent);
524
+ }
525
+ }
526
+ if (localPlan) applyLocalIgnore(localPlan);
527
+ } catch (error) {
528
+ const failures = [];
529
+ restoring = true;
530
+ for (const value of [...touched].reverse()) {
531
+ try {
532
+ if (fingerprint(capture(value)) !== currentHashes.get(value)) fail(`Mudanca concorrente preservada: ${value}`);
533
+ restore(value, originals.get(value));
534
+ } catch (failure) { failures.push(failure); }
535
+ }
536
+ for (const [directory, mode] of [...prunedDirectories].sort(([left], [right]) => right.length - left.length)) {
537
+ try {
538
+ if (!stat(safe(directory))) ensureDirectory(`${directory}/.restore-probe`);
539
+ const info = stat(safe(directory));
540
+ if (!info?.isDirectory() || !restoredDirectories.has(directory) && (info.mode & 0o777) !== mode) fail(`Diretorio concorrente preservado: ${directory}`);
541
+ chmodSync(safe(directory), mode);
542
+ } catch (failure) { failures.push(failure); }
543
+ }
544
+ for (const directory of sorted(createdDirectories).sort((a, b) => b.length - a.length)) {
545
+ try { if (!originalDirectories.has(directory) && stat(safe(directory))?.isDirectory() && !readdirSync(absolute(directory)).length) rmdirSync(safe(directory)); }
546
+ catch (failure) { failures.push(failure); }
547
+ }
548
+ if (failures.length) throw new AggregateError([error, ...failures], `${error.message}; rollback incompleto: ${failures.map(failure => failure.message).join('; ')}`);
549
+ throw error;
446
550
  }
447
- for (const agent of agents.keys()) for (const kind of [...Object.keys(principals), 'instructions', 'prompts']) pruneEmpty(`${agent}/${kind}`);
448
- const omittedRows = new Set([...legacy, ...missing.map(native => native.index)]);
449
- const finalConfig = rows.filter((row, index) => !omittedRows.has(index)).join('\n').replace(/\n*$/, '\n');
450
- if (!configExists || configText !== finalConfig) atomic(config, finalConfig);
451
551
  return { ...summary, ownedOutputs, exitCode: 0, ...(backupRoot ? { backupRoot } : {}) };
452
552
  }
453
553