@pathbit/agentdevkit 0.0.6 → 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) {
@@ -114,6 +115,107 @@ function rootProbes(root, roots) {
114
115
  return roots.filter(path => { const item = localStat(join(root, path)); return !item || item.isDirectory(); }).map(path => `${path}/.agentdevkit-ignore-probe`);
115
116
  }
116
117
 
118
+ function artifactPolicy(templateFile) {
119
+ const adjacent = join(dirname(templateFile), 'agents.ignore');
120
+ const source = checkedLocalPath(localStat(adjacent) ? adjacent : fileURLToPath(new URL('../../templates/gitignore/agents.ignore', import.meta.url)));
121
+ const content = readFileSync(source, 'utf8');
122
+ const section = content.split(/^## Resultados e arquivos temporarios\r?$/m)[1]?.split(/^## /m)[0];
123
+ const patterns = section?.split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#'));
124
+ if (!patterns?.length || patterns.some(pattern => !pattern.startsWith('**/') || /[!\\\x00-\x1f]/.test(pattern) || pattern.split('/').includes('..'))) throw new Error('Secao de artefatos invalida em agents.ignore.');
125
+ return patterns;
126
+ }
127
+
128
+ function artifactProbes(root, patterns) {
129
+ const probes = new Set();
130
+ for (const pattern of patterns) {
131
+ const stem = pattern.slice(3).replace(/\/$/, '').replace(/\[([^\]]+)\]/g, (_match, letters) => letters[0]).replace(/\?/g, 'x');
132
+ const names = stem.includes('*') ? ['', '-mcp', '-report', '-cache', '-agentdevkit'].map(suffix => stem.replace(/\*/g, suffix)) : [stem];
133
+ for (const name of names) for (const variant of new Set([name, name.toLowerCase()])) {
134
+ const leaves = stem.includes('*') ? ['.agentdevkit-artifact-probe', '.cache/.agentdevkit-artifact-probe'] : ['.agentdevkit-artifact-probe'];
135
+ for (const leaf of leaves) for (const path of [`${variant}/${leaf}`, `.agentdevkit-ignore-probes/${variant}/${leaf}`]) {
136
+ let parent = root, safe = true;
137
+ for (const part of path.split('/').slice(0, -1)) {
138
+ parent = join(parent, part); const item = localStat(parent);
139
+ if (item && !item.isDirectory()) { safe = false; break; }
140
+ }
141
+ if (safe) probes.add(path);
142
+ }
143
+ }
144
+ }
145
+ return [...probes];
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
+
203
+ function localIgnoreSource(location, source) {
204
+ const path = resolve(location.top, source);
205
+ if (path === location.exclude) return true;
206
+ const inside = relative(location.top, path);
207
+ return basename(path) === '.gitignore' && !isAbsolute(inside) && inside !== '..' && !inside.startsWith(`..${sep}`);
208
+ }
209
+
210
+ function ignoreMatches(location, probes) {
211
+ const paths = probes.map(path => location.prefix ? `${location.prefix}/${path}` : path);
212
+ const result = gitCommand(location.top, ['-c', 'core.excludesFile=', 'check-ignore', '--verbose', '--no-index', '--stdin', '-z'], paths.join('\0') + '\0');
213
+ if (result.error || ![0, 1].includes(result.status)) throw new Error(result.error?.message || result.stderr || 'Falha ao conferir exclusoes Git.');
214
+ const values = result.stdout.split('\0'), matches = new Map();
215
+ for (let index = 0; index + 3 < values.length; index += 4) matches.set(values[index + 3], { source: values[index], line: Number(values[index + 1]), pattern: values[index + 2] });
216
+ return { paths, matches };
217
+ }
218
+
117
219
  function localRoot(value) {
118
220
  const missing = []; let parent = resolve(value);
119
221
  while (!localStat(parent)) { missing.unshift(parent.slice(dirname(parent).length + 1)); parent = dirname(parent); }
@@ -128,6 +230,7 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
128
230
  const localTemplate = join(root, '.pbai4devs/templates/gitignore/local.ignore');
129
231
  const templateFile = checkedLocalPath(templateRoot ? join(templateRoot, '.pbai4devs/templates/gitignore/local.ignore') : localStat(localTemplate) ? localTemplate : fileURLToPath(new URL('../../templates/gitignore/local.ignore', import.meta.url)));
130
232
  const template = readFileSync(templateFile, 'utf8');
233
+ const artifacts = artifactPolicy(templateFile);
131
234
  if (template.split('{{OUTPUTS}}').length !== 2) throw new Error('local.ignore exige um unico {{OUTPUTS}}.');
132
235
  if (template.split('{{ROOTS}}').length > 2) throw new Error('local.ignore permite um unico {{ROOTS}}.');
133
236
  const localPolicy = join(templateRoot || root, '.pbai4devs/templates/clients/ADAPTERS.json');
@@ -152,24 +255,26 @@ export function planLocalIgnore(root, { outputs = [], templateRoot } = {}) {
152
255
  }
153
256
  const id = createHash('sha256').update(root).digest('hex').slice(0, 16);
154
257
  const first = `# BEGIN AGENTDEVKIT LOCAL ${id}`, last = `# END AGENTDEVKIT LOCAL ${id}`;
155
- const fixed = template.replace('{{ROOTS}}', roots.map(path => `/${path}/`).join('\n')).replace('{{OUTPUTS}}', paths.filter(path => !roots.some(root => path === root || path.startsWith(root + '/'))).map(path => `/${escapePattern(path)}`).join('\n'));
258
+ const fixed = template.replace('{{ROOTS}}', roots.map(path => `/${path}/`).join('\n')).replace('{{OUTPUTS}}', paths.filter(path => !roots.some(root => path === root || path.startsWith(root + '/'))).map(path => `/${escapePattern(path)}`).join('\n'))
259
+ + '\n# Resultados e arquivos temporarios\n' + artifacts.map(pattern => `/${pattern}`).join('\n') + '\n';
156
260
  const body = fixed.split(/\r?\n/).map(line => location?.prefix && line.startsWith('/') ? `/${escapePattern(location.prefix)}${line}` : line).join('\n');
157
261
  const target = location?.exclude ?? checkedLocalPath(join(root, '.gitignore'));
158
262
  const before = localRead(target), after = localBlock(before, first, last, body);
159
- const edits = before === after ? [] : [{ path: target, before, after }];
263
+ const edits = before === after ? [] : [{ path: target, before, after, beforeMode: localMode(target) }];
160
264
  const probes = [...paths, ...rootProbes(root, roots)];
265
+ const artifactPaths = [...new Set([...artifactProbes(root, artifacts), ...(location ? visibleArtifactProbes(root, location, artifacts) : [])])];
161
266
  const wanted = new Set(probes.map(path => location?.prefix ? `${location.prefix}/${path}` : path));
162
267
  if (location && !before?.includes(first) && !localRead(join(root, '.gitignore'))?.includes(first)) {
163
- const ignored = gitCommand(location.top, ['check-ignore', '--no-index', '--stdin', '-z'], [...wanted].join('\0') + '\0');
164
- if (!ignored.error && [0, 1].includes(ignored.status) && ignored.stdout.split('\0').filter(Boolean).length === wanted.size) edits.length = 0;
268
+ const ignored = ignoreMatches(location, [...probes, ...artifactPaths]);
269
+ if (ignored.paths.every(path => { const match = ignored.matches.get(path); return match && !match.pattern.startsWith('!') && localIgnoreSource(location, match.source); })) edits.length = 0;
165
270
  }
166
271
  if (location) {
167
272
  const fallback = checkedLocalPath(join(root, '.gitignore'));
168
273
  const old = localRead(fallback), next = localBlock(old, first, last, null);
169
- 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) });
170
275
  }
171
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) : [];
172
- return { root, status: location ? 'git' : 'fallback', location, paths, roots, edits, trackedOutputs: tracked, first, last };
277
+ return { root, status: location ? 'git' : 'fallback', location, paths, roots, artifactPatterns: artifacts, artifactProbes: artifactPaths, edits, trackedOutputs: tracked, first, last };
173
278
  }
174
279
 
175
280
  export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
@@ -185,19 +290,17 @@ export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
185
290
  if (createHash('sha256').update(block).digest('hex') !== item.sha256) throw new Error('Bloco de ignore mudou antes da verificacao.');
186
291
  return { ...item, path };
187
292
  });
188
- if (plan.location && plan.paths.length) {
189
- const probes = [...plan.paths, ...rootProbes(plan.root, plan.roots || [])];
190
- const relativePaths = probes.map(path => plan.location.prefix ? `${plan.location.prefix}/${path}` : path);
191
- const result = gitCommand(plan.location.top, ['check-ignore', '--verbose', '--no-index', '--stdin', '-z'], relativePaths.join('\0') + '\0');
192
- if (result.error || ![0, 1].includes(result.status)) throw new Error(result.error?.message || result.stderr || 'Falha ao conferir exclusoes Git.');
193
- const values = result.stdout.split('\0'), matches = new Map();
194
- for (let index = 0; index + 3 < values.length; index += 4) matches.set(values[index + 3], { source: values[index], line: Number(values[index + 1]), pattern: values[index + 2] });
293
+ if (plan.location && (plan.paths.length || plan.artifactProbes?.length)) {
294
+ const probes = [...new Set([...plan.paths, ...rootProbes(plan.root, plan.roots || []), ...(plan.artifactProbes || []),
295
+ ...(plan.artifactPatterns ? visibleArtifactProbes(plan.root, plan.location, plan.artifactPatterns) : [])])];
296
+ const { paths: relativePaths, matches } = ignoreMatches(plan.location, probes);
195
297
  for (let index = 0; index < relativePaths.length; index++) {
196
298
  const match = matches.get(relativePaths[index]);
197
299
  const negative = match?.pattern.startsWith('!');
198
- const humanOverride = negative && /(?:^|[\\/])\.gitignore$/.test(match.source);
300
+ const local = match && localIgnoreSource(plan.location, match.source);
301
+ const humanOverride = negative && local && /(?:^|[\\/])\.gitignore$/.test(match.source);
199
302
  const replaced = negative && replacements.some(item => item.path === canonicalExisting(resolve(plan.location.top, match.source)) && match.line >= item.firstLine && match.line <= item.lastLine);
200
- if (!replaced && ((!plan.edits.length && (!match || negative)) || humanOverride)) missing.push(probes[index]);
303
+ if (!replaced && ((!plan.edits.length && (!match || negative || !local)) || humanOverride)) missing.push(probes[index]);
201
304
  }
202
305
  }
203
306
  return { ok: plan.edits.length === 0 && missing.length === 0 && plan.trackedOutputs.length === 0, status: plan.status, missing, trackedOutputs: plan.trackedOutputs, changed: plan.edits.length };
@@ -206,28 +309,42 @@ export function checkLocalIgnore(plan, { replacedBlocks = [] } = {}) {
206
309
  export function assertLocalIgnoreUnchanged(plan) {
207
310
  for (const edit of plan.edits) {
208
311
  checkedLocalPath(edit.path);
209
- 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}`);
210
313
  }
211
314
  }
212
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
+
213
327
  export function applyLocalIgnore(plan) {
214
328
  assertLocalIgnoreUnchanged(plan);
329
+ const before = checkLocalIgnore(plan);
330
+ if (before.missing.length) throw new Error(`Regras humanas reabrem derivados locais: ${before.missing.join(', ')}`);
215
331
  const written = [];
216
332
  try {
217
333
  for (const edit of plan.edits) {
218
334
  mkdirSync(dirname(edit.path), { recursive: true });
219
- const temporary = `${edit.path}.agentdevkit-${randomUUID()}`;
220
- try { writeFileSync(temporary, edit.after, { flag: 'wx' }); renameSync(temporary, edit.path); }
221
- finally { rmSync(temporary, { force: true }); }
222
- written.push(edit);
335
+ atomicLocalWrite(edit.path, edit.after, edit.beforeMode, mode => written.push({ edit, mode }));
223
336
  }
224
337
  const verified = checkLocalIgnore({ ...plan, edits: [], trackedOutputs: [] });
225
338
  if (verified.missing.length) throw new Error(`Regras humanas reabrem derivados locais: ${verified.missing.join(', ')}`);
226
339
  } catch (error) {
227
- for (const edit of written.reverse()) {
228
- if (localRead(edit.path) !== edit.after) continue;
229
- 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); }
230
346
  }
347
+ if (rollbackErrors.length) throw new AggregateError([error, ...rollbackErrors], `${error.message}; rollback incompleto: ${rollbackErrors.map(failure => failure.message).join('; ')}`, { cause: error });
231
348
  throw error;
232
349
  }
233
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; });