@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.
- package/.pbai4devs/scripts/lib/gitignore.mjs +142 -25
- package/.pbai4devs/scripts/preparar-ferramentas/SCRIPT.mjs +41 -20
- package/.pbai4devs/scripts/preparar-ferramentas/tests/TEST.mjs +81 -0
- package/.pbai4devs/scripts/sincronizar-agentes/SCRIPT.mjs +120 -20
- package/.pbai4devs/scripts/sincronizar-agentes/tests/HUMAN_OWNERSHIP.mjs +145 -0
- package/.pbai4devs/scripts/sincronizar-agentes/tests/LOCAL_IGNORE.mjs +211 -1
- package/.pbai4devs/scripts/sincronizar-agentes/tests/TEST.mjs +2 -1
- package/CHANGELOG.md +16 -0
- package/README.en.md +2 -2
- package/README.es.md +2 -2
- package/README.md +2 -2
- package/docs/guides/cli.md +9 -0
- package/docs/guides/installation.md +17 -2
- package/docs/guides/tools-and-testing.md +2 -0
- package/docs/maintainers/packages.md +1 -1
- package/docs/product/roadmap.md +4 -0
- package/docs/specs/README.md +2 -0
- package/docs/specs/v0.0.7/README.md +10 -0
- package/docs/specs/v0.0.7/SPEC.md +59 -0
- package/docs/specs/v0.0.8/README.md +10 -0
- package/docs/specs/v0.0.8/SPEC.md +62 -0
- package/package.json +3 -2
- package/src/cli.mjs +10 -4
- package/src/install.mjs +61 -22
|
@@ -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 =
|
|
164
|
-
if (
|
|
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
|
-
|
|
191
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
328
|
-
|
|
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) {
|
|
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
|
-
|
|
390
|
-
const stage = mkdtempSync(join(local, '.stage-'));
|
|
391
|
-
const temporary = join(local, `CURRENT.${
|
|
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-${
|
|
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; });
|