@marvinribeiro_/ignity 0.0.0-stage → 2.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +43 -2
  4. package/bin/ignity.js +13 -0
  5. package/content/adapters/AGENTS.md +11 -0
  6. package/content/adapters/CLAUDE.md +11 -0
  7. package/content/framework/FRAMEWORK.md +150 -0
  8. package/content/skills/elementor/CHANGELOG.md +16 -0
  9. package/content/skills/elementor/SKILL.md +92 -0
  10. package/content/skills/elementor/references/operacao.md +35 -0
  11. package/content/skills/web-assets/SKILL.md +25 -0
  12. package/content/skills/web-figma-spec/SKILL.md +27 -0
  13. package/content/skills/web-motion/SKILL.md +32 -0
  14. package/content/skills/web-orchestrator/SKILL.md +49 -0
  15. package/content/skills/web-qa/SKILL.md +34 -0
  16. package/content/skills/web-release/SKILL.md +22 -0
  17. package/content/skills/web-seo/SKILL.md +28 -0
  18. package/content/skills/web-setup/SKILL.md +51 -0
  19. package/content/templates/ASSETS.csv +1 -0
  20. package/content/templates/BRIEF.md +75 -0
  21. package/content/templates/COPY.md +8 -0
  22. package/content/templates/DECISIONS.md +12 -0
  23. package/content/templates/IGNITY-FEEDBACK.md +34 -0
  24. package/content/templates/INICIAR.md +11 -0
  25. package/content/templates/QA.md +35 -0
  26. package/content/templates/RETOMAR.md +9 -0
  27. package/content/templates/SECTIONS.md +12 -0
  28. package/content/templates/STATE.md +52 -0
  29. package/package.json +24 -4
  30. package/src/cli.js +124 -0
  31. package/src/commands/doctor.js +214 -0
  32. package/src/commands/init.js +36 -0
  33. package/src/commands/status.js +67 -0
  34. package/src/commands/uninstall.js +74 -0
  35. package/src/commands/update.js +13 -0
  36. package/src/data/v1-hashes.json +44 -0
  37. package/src/lib/backup.js +52 -0
  38. package/src/lib/client-detect.js +19 -0
  39. package/src/lib/files.js +86 -0
  40. package/src/lib/log.js +44 -0
  41. package/src/lib/manifest.js +71 -0
  42. package/src/lib/mcp-config.js +69 -0
  43. package/src/lib/merger.js +95 -0
  44. package/src/lib/node-check.js +28 -0
  45. package/src/lib/paths.js +91 -0
  46. package/src/lib/prompt.js +30 -0
  47. package/src/lib/sanitize-url.js +14 -0
  48. package/src/lib/sync.js +251 -0
  49. package/src/lib/v1-detect.js +46 -0
  50. package/src/lib/writer.js +81 -0
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AC 4 and AC 5: `.ignity/manifest.json`. Holds only versions, client, dates,
5
+ * relative paths and hashes — never environment values, tokens or URLs (Q-04).
6
+ */
7
+
8
+ const fs = require('node:fs');
9
+ const { MANIFEST_PATH, CLIENTS, isSafeRelative, resolveInside } = require('./paths');
10
+ const { atomicWriteFile, sha256, sha256File } = require('./files');
11
+
12
+ const HEX64 = /^[0-9a-f]{64}$/;
13
+
14
+ /** @returns {string[]} validation problems (empty when valid) */
15
+ function validateManifest(data) {
16
+ const problems = [];
17
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return ['o manifesto não é um objeto JSON'];
18
+ if (typeof data.ignityVersion !== 'string' || !data.ignityVersion) problems.push('ignityVersion ausente');
19
+ if (!CLIENTS.includes(data.client)) problems.push(`client inválido (use ${CLIENTS.join(', ')})`);
20
+ for (const key of ['installedAt', 'updatedAt']) {
21
+ if (typeof data[key] !== 'string' || Number.isNaN(Date.parse(data[key]))) problems.push(`${key} inválido`);
22
+ }
23
+ if (!Array.isArray(data.files)) return [...problems, 'files[] ausente'];
24
+ const seen = new Set();
25
+ data.files.forEach((f, i) => {
26
+ const label = `files[${i}]`;
27
+ if (!f || typeof f !== 'object') return problems.push(`${label} não é objeto`);
28
+ if (!isSafeRelative(f.path)) problems.push(`${label}: caminho inseguro`);
29
+ else if (seen.has(f.path)) problems.push(`${label}: caminho duplicado ${f.path}`);
30
+ else seen.add(f.path);
31
+ if (f.owner !== 'ignity') problems.push(`${label}: owner deve ser "ignity"`);
32
+ if (typeof f.created !== 'boolean') problems.push(`${label}: created deve ser booleano`);
33
+ if (f.kind === 'merged-block') {
34
+ if (!HEX64.test(f.blockSha256 || '')) problems.push(`${label}: blockSha256 inválido`);
35
+ } else if (f.kind !== undefined) problems.push(`${label}: kind desconhecido`);
36
+ else if (!HEX64.test(f.sha256 || '')) problems.push(`${label}: sha256 inválido`);
37
+ });
38
+ return problems;
39
+ }
40
+
41
+ /**
42
+ * @returns {object|null} parsed manifest, or null when it does not exist
43
+ * @throws when the manifest exists but is unreadable or invalid
44
+ */
45
+ function readManifest(root) {
46
+ const file = resolveInside(root, MANIFEST_PATH);
47
+ if (!fs.existsSync(file)) return null;
48
+ let data;
49
+ try {
50
+ data = JSON.parse(fs.readFileSync(file, 'utf8'));
51
+ } catch {
52
+ // The parser error is not echoed: it may quote file content.
53
+ throw new Error(`${MANIFEST_PATH} não é um JSON válido.`);
54
+ }
55
+ const problems = validateManifest(data);
56
+ if (problems.length) throw new Error(`${MANIFEST_PATH} inválido: ${problems.join('; ')}.`);
57
+ return data;
58
+ }
59
+
60
+ function serializeManifest(data) {
61
+ const files = [...data.files].sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)));
62
+ return `${JSON.stringify({ ...data, files }, null, 2)}\n`;
63
+ }
64
+
65
+ function writeManifest(root, data) {
66
+ const problems = validateManifest(data);
67
+ if (problems.length) throw new Error(`Manifesto gerado inválido: ${problems.join('; ')}.`);
68
+ atomicWriteFile(resolveInside(root, MANIFEST_PATH), serializeManifest(data));
69
+ }
70
+
71
+ module.exports = { validateManifest, readManifest, writeManifest, serializeManifest, sha256, sha256File };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AC 17: reads ONLY server names from MCP client configs. Values of env, headers,
5
+ * args and URLs are never returned, so they can never reach the output (Q-04).
6
+ */
7
+
8
+ const fs = require('node:fs');
9
+ const os = require('node:os');
10
+ const path = require('node:path');
11
+
12
+ function namesFromJson(text, projectRoot) {
13
+ const data = JSON.parse(text);
14
+ const names = [];
15
+ const take = (servers) => {
16
+ if (servers && typeof servers === 'object' && !Array.isArray(servers)) names.push(...Object.keys(servers));
17
+ };
18
+ take(data && data.mcpServers);
19
+ // ~/.claude.json keeps per-project servers under projects[<abs path>].mcpServers.
20
+ if (projectRoot && data && data.projects && typeof data.projects === 'object') {
21
+ const entry = data.projects[projectRoot];
22
+ take(entry && entry.mcpServers);
23
+ }
24
+ return names;
25
+ }
26
+
27
+ const TOML_TABLE = /^\s*\[\s*mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))/;
28
+
29
+ function namesFromToml(text) {
30
+ const names = [];
31
+ for (const line of text.split(/\r?\n/)) {
32
+ const m = TOML_TABLE.exec(line);
33
+ if (m) names.push(m[1] || m[2] || m[3]);
34
+ }
35
+ return names;
36
+ }
37
+
38
+ /** Config files that may declare MCP servers, per client. Labels use `~` instead of the home path. */
39
+ function mcpSources(root, client, home = os.homedir()) {
40
+ const sources = [];
41
+ if (client === 'claude' || client === 'both') {
42
+ sources.push({ label: '.mcp.json', file: path.join(root, '.mcp.json'), format: 'json' });
43
+ sources.push({ label: '~/.claude.json', file: path.join(home, '.claude.json'), format: 'json', projectRoot: root });
44
+ }
45
+ if (client === 'codex' || client === 'both') {
46
+ sources.push({ label: '.codex/config.toml', file: path.join(root, '.codex', 'config.toml'), format: 'toml' });
47
+ sources.push({ label: '~/.codex/config.toml', file: path.join(home, '.codex', 'config.toml'), format: 'toml' });
48
+ }
49
+ return sources;
50
+ }
51
+
52
+ /**
53
+ * @returns {{label: string, exists: boolean, readable: boolean, names: string[]}[]}
54
+ */
55
+ function readMcpServerNames(root, client, home) {
56
+ return mcpSources(root, client, home).map((src) => {
57
+ if (!fs.existsSync(src.file)) return { label: src.label, exists: false, readable: false, names: [] };
58
+ try {
59
+ const text = fs.readFileSync(src.file, 'utf8');
60
+ const names = src.format === 'json' ? namesFromJson(text, src.projectRoot) : namesFromToml(text);
61
+ return { label: src.label, exists: true, readable: true, names: [...new Set(names)] };
62
+ } catch {
63
+ // Parser errors may quote file content (and secrets): never propagate them.
64
+ return { label: src.label, exists: true, readable: false, names: [] };
65
+ }
66
+ });
67
+ }
68
+
69
+ module.exports = { readMcpServerNames, mcpSources, namesFromJson, namesFromToml };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AC 8 and AC 10: merges the Ignity block into CLAUDE.md / AGENTS.md.
5
+ * Only the text between the markers is ever owned by Ignity; everything else is
6
+ * preserved byte for byte. The original line ending (LF or CRLF) is kept.
7
+ */
8
+
9
+ const START = '<!-- IGNITY:START -->';
10
+ const END = '<!-- IGNITY:END -->';
11
+
12
+ function detectEol(text) {
13
+ return typeof text === 'string' && text.includes('\r\n') ? '\r\n' : '\n';
14
+ }
15
+
16
+ function normalizeBody(body, eol) {
17
+ return body.replace(/\r\n/g, '\n').replace(/\n+$/, '').split('\n').join(eol);
18
+ }
19
+
20
+ /** Locates the marker pair. Returns null without markers; throws when they are malformed. */
21
+ function findBlock(text) {
22
+ const starts = text.split(START).length - 1;
23
+ const ends = text.split(END).length - 1;
24
+ if (starts === 0 && ends === 0) return null;
25
+ const start = text.indexOf(START);
26
+ const end = text.indexOf(END);
27
+ if (starts !== 1 || ends !== 1 || end < start) {
28
+ throw new Error(
29
+ `Marcadores ${START} / ${END} malformados (esperado um par, nesta ordem). ` +
30
+ 'Corrija o arquivo à mão, deixando um único par ou nenhum, e rode o comando de novo.',
31
+ );
32
+ }
33
+ return { start, end: end + END.length };
34
+ }
35
+
36
+ function hasBlock(text) {
37
+ return typeof text === 'string' && findBlock(text) !== null;
38
+ }
39
+
40
+ function renderBlock(body, eol) {
41
+ return `${START}${eol}${normalizeBody(body, eol)}${eol}${END}`;
42
+ }
43
+
44
+ /**
45
+ * @param {string|null} existing current file content (null when the file does not exist)
46
+ * @param {string} body adapter content
47
+ * @returns {string} new file content
48
+ */
49
+ function upsertBlock(existing, body) {
50
+ if (existing === null || existing === undefined || existing === '') return `${renderBlock(body, '\n')}\n`;
51
+ const eol = detectEol(existing);
52
+ const block = findBlock(existing);
53
+ if (block) return existing.slice(0, block.start) + renderBlock(body, eol) + existing.slice(block.end);
54
+ const sep = existing.endsWith('\n') ? eol : eol + eol;
55
+ return `${existing}${sep}${renderBlock(body, eol)}${eol}`;
56
+ }
57
+
58
+ /** Replaces the first occurrence of `oldText` (LF or CRLF variant) with the block. Returns null if absent. */
59
+ function replaceTextWithBlock(existing, oldText, body) {
60
+ const eol = detectEol(existing);
61
+ for (const candidate of [oldText, oldText.replace(/\r?\n/g, '\r\n')]) {
62
+ const idx = existing.indexOf(candidate);
63
+ if (idx < 0) continue;
64
+ const trailing = candidate.endsWith('\n') ? eol : '';
65
+ return existing.slice(0, idx) + renderBlock(body, eol) + trailing + existing.slice(idx + candidate.length);
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /** Removes the block (and the blank separator line the merger added). Returns the text unchanged without markers. */
71
+ function removeBlock(existing) {
72
+ const block = findBlock(existing);
73
+ if (!block) return existing;
74
+ const eol = detectEol(existing);
75
+ let before = existing.slice(0, block.start);
76
+ let after = existing.slice(block.end);
77
+ if (after.startsWith(eol)) after = after.slice(eol.length);
78
+ else if (after.startsWith('\n')) after = after.slice(1);
79
+ if (after === '' && before.endsWith(eol + eol)) before = before.slice(0, -eol.length);
80
+ return before + after;
81
+ }
82
+
83
+ /** Body between the markers (LF-normalized) or null. */
84
+ function extractBody(existing) {
85
+ if (typeof existing !== 'string') return null;
86
+ const block = findBlock(existing);
87
+ if (!block) return null;
88
+ return existing
89
+ .slice(block.start + START.length, block.end - END.length)
90
+ .replace(/\r\n/g, '\n')
91
+ .replace(/^\n/, '')
92
+ .replace(/\n$/, '');
93
+ }
94
+
95
+ module.exports = { START, END, upsertBlock, removeBlock, replaceTextWithBlock, extractBody, hasBlock, normalizeBody };
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AC 1a: runtime Node.js version check.
5
+ *
6
+ * This file is required by bin/ignity.js before anything else, so it must parse
7
+ * on old Node versions: ES5 syntax only (var, function, string concatenation).
8
+ */
9
+
10
+ var REQUIRED_MAJOR = 22;
11
+
12
+ /**
13
+ * @param {string} versionString e.g. "22.1.0" or "v20.11.0"
14
+ * @returns {{ok: boolean, found: string, major: number, required: number, message: string}}
15
+ */
16
+ function checkNodeVersion(versionString) {
17
+ var found = String(versionString || '').replace(/^v/, '');
18
+ var major = parseInt(found.split('.')[0], 10);
19
+ var ok = !isNaN(major) && major >= REQUIRED_MAJOR;
20
+ var message = ok
21
+ ? 'Node.js ' + found + ' (mínimo exigido: ' + REQUIRED_MAJOR + ').'
22
+ : 'O Ignity precisa do Node.js ' + REQUIRED_MAJOR + ' ou mais novo. Versão encontrada: ' +
23
+ (found || 'desconhecida') + '. Mínima exigida: ' + REQUIRED_MAJOR + '.\n' +
24
+ 'Próximo passo: instale o Node 22 LTS ou mais novo em https://nodejs.org/ e rode o comando de novo.';
25
+ return { ok: ok, found: found, major: major, required: REQUIRED_MAJOR, message: message };
26
+ }
27
+
28
+ module.exports = { checkNodeVersion: checkNodeVersion, REQUIRED_MAJOR: REQUIRED_MAJOR };
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const PKG_ROOT = path.resolve(__dirname, '..', '..');
7
+ const CONTENT_DIR = path.join(PKG_ROOT, 'content');
8
+ const SKILLS_DIR = path.join(CONTENT_DIR, 'skills');
9
+ const TEMPLATES_DIR = path.join(CONTENT_DIR, 'templates');
10
+ const ADAPTERS_DIR = path.join(CONTENT_DIR, 'adapters');
11
+ const FRAMEWORK_SOURCE = path.join(CONTENT_DIR, 'framework', 'FRAMEWORK.md');
12
+
13
+ const IGNITY_DIR = '.ignity';
14
+ const MANIFEST_PATH = '.ignity/manifest.json';
15
+ const FRAMEWORK_PATH = '.ignity/framework/FRAMEWORK.md';
16
+ const BACKUPS_DIR = '.ignity/backups';
17
+ const PROJECT_DIR = 'project';
18
+ const V1_FRAMEWORK_PATH = 'project/framework/FRAMEWORK.md';
19
+
20
+ const CLIENTS = ['claude', 'codex', 'both'];
21
+ const CLIENT_FOLDERS = { claude: ['.claude'], codex: ['.agents'], both: ['.claude', '.agents'] };
22
+ const CLIENT_ADAPTERS = { claude: ['CLAUDE.md'], codex: ['AGENTS.md'], both: ['CLAUDE.md', 'AGENTS.md'] };
23
+
24
+ /** A manifest/relative path is safe when it is POSIX, relative and has no `..`, `.` or empty segments. */
25
+ function isSafeRelative(rel) {
26
+ if (typeof rel !== 'string' || rel === '' || rel.includes('\\') || rel.includes('\0')) return false;
27
+ if (rel.startsWith('/') || /^[A-Za-z]:/.test(rel)) return false;
28
+ return rel.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
29
+ }
30
+
31
+ function isInside(root, abs) {
32
+ return abs === root || abs.startsWith(root + path.sep);
33
+ }
34
+
35
+ /**
36
+ * Resolves `rel` inside `root` (which must already be a real path). Throws on path
37
+ * traversal and when an existing ancestor is a symlink that escapes `root`.
38
+ */
39
+ function resolveInside(root, rel) {
40
+ if (!isSafeRelative(rel)) throw new Error(`Caminho inseguro recusado: ${JSON.stringify(rel)}`);
41
+ const abs = path.resolve(root, ...rel.split('/'));
42
+ if (!isInside(root, abs)) throw new Error(`Caminho fora da pasta do projeto recusado: ${rel}`);
43
+ let probe = abs;
44
+ while (probe !== root && !fs.existsSync(probe)) probe = path.dirname(probe);
45
+ if (!isInside(root, fs.realpathSync(probe))) {
46
+ throw new Error(`Caminho que aponta para fora da pasta do projeto (link simbólico) recusado: ${rel}`);
47
+ }
48
+ return abs;
49
+ }
50
+
51
+ function isProjectPath(rel) {
52
+ return rel === PROJECT_DIR || rel.startsWith(`${PROJECT_DIR}/`);
53
+ }
54
+
55
+ // Resolve existing ancestors so aliases into project/ cannot bypass its guard,
56
+ // including when the final file does not exist yet.
57
+ function canonicalPath(abs) {
58
+ let probe = abs;
59
+ while (!fs.existsSync(probe)) {
60
+ const parent = path.dirname(probe);
61
+ if (parent === probe) break;
62
+ probe = parent;
63
+ }
64
+ return path.resolve(fs.realpathSync(probe), path.relative(probe, abs));
65
+ }
66
+
67
+ function resolvesToProject(root, abs) {
68
+ return isInside(canonicalPath(path.join(root, PROJECT_DIR)), canonicalPath(abs));
69
+ }
70
+
71
+ module.exports = {
72
+ PKG_ROOT,
73
+ CONTENT_DIR,
74
+ SKILLS_DIR,
75
+ TEMPLATES_DIR,
76
+ ADAPTERS_DIR,
77
+ FRAMEWORK_SOURCE,
78
+ IGNITY_DIR,
79
+ MANIFEST_PATH,
80
+ FRAMEWORK_PATH,
81
+ BACKUPS_DIR,
82
+ PROJECT_DIR,
83
+ V1_FRAMEWORK_PATH,
84
+ CLIENTS,
85
+ CLIENT_FOLDERS,
86
+ CLIENT_ADAPTERS,
87
+ isSafeRelative,
88
+ resolveInside,
89
+ isProjectPath,
90
+ resolvesToProject,
91
+ };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+ const readline = require('node:readline/promises');
3
+ const { sanitizeUrl } = require('./sanitize-url');
4
+ const { CliError } = require('./log');
5
+ function environment(value) {
6
+ value = value || 'staging';
7
+ if (!['staging', 'produção', 'producao', 'production'].includes(value)) throw new CliError('Ambiente deve ser produção ou staging.');
8
+ return value === 'staging' ? value : 'produção';
9
+ }
10
+ function clean(value) { return (value || 'desconhecido').replace(/[\r\n\x00-\x1f\x7f]/g, ' '); }
11
+ function answers(flags = {}) {
12
+ return { site: sanitizeUrl(flags['site-url']), env: environment(flags.env), figma: sanitizeUrl(flags.figma, true), assets: clean(flags.assets || './assets'), scope: clean(flags.scope) };
13
+ }
14
+ async function ask(flags, out) {
15
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
16
+ async function question(label, initial, validate) {
17
+ if (initial !== undefined) return validate(initial);
18
+ for (;;) {
19
+ try { return validate(await rl.question(label)); } catch (e) { out.warn(e.message); }
20
+ }
21
+ }
22
+ try {
23
+ const site = await question('URL do site WordPress (ambiente entre parênteses, padrão staging): ', flags['site-url'], value => {
24
+ const match = value.match(/^(.*?)\s*\(([^)]+)\)\s*$/);
25
+ return { site: sanitizeUrl(match ? match[1].trim() : value), env: environment(flags.env || (match && match[2])) };
26
+ });
27
+ return { ...site, figma: await question('Link do Figma: ', flags.figma, v => sanitizeUrl(v, true)), assets: await question('Pasta dos assets [./assets]: ', flags.assets, v => clean(v || './assets')), scope: await question('O que construir agora: ', flags.scope, clean) };
28
+ } finally { rl.close(); }
29
+ }
30
+ module.exports = { answers, ask };
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+ const { CliError } = require('./log');
3
+ function sanitizeUrl(value, figma = false) {
4
+ if (!value || value === 'desconhecido') return 'desconhecido';
5
+ let url;
6
+ try { url = new URL(value); } catch { throw new CliError('Informe uma URL http ou https válida, sem credenciais.'); }
7
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new CliError('Informe uma URL http ou https sem credenciais embutidas.');
8
+ if (figma && !['figma.com', 'www.figma.com'].includes(url.hostname)) throw new CliError('O link do Figma deve usar figma.com ou www.figma.com.');
9
+ const node = figma && url.searchParams.get('node-id');
10
+ url.search = ''; url.hash = '';
11
+ if (node) url.searchParams.set('node-id', node);
12
+ return url.toString();
13
+ }
14
+ module.exports = { sanitizeUrl };
@@ -0,0 +1,251 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared engine of `init` and `update`: computes the package layout for a client,
5
+ * reconciles it with what is on disk (v2 manifest or v1 hashes as baseline),
6
+ * merges the adapter blocks and rewrites the manifest.
7
+ */
8
+
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const {
12
+ SKILLS_DIR,
13
+ TEMPLATES_DIR,
14
+ ADAPTERS_DIR,
15
+ FRAMEWORK_SOURCE,
16
+ FRAMEWORK_PATH,
17
+ MANIFEST_PATH,
18
+ V1_FRAMEWORK_PATH,
19
+ CLIENT_FOLDERS,
20
+ CLIENT_ADAPTERS,
21
+ resolveInside,
22
+ } = require('./paths');
23
+ const { listFiles, sha256, sha256File, readIfExists } = require('./files');
24
+ const { readManifest, writeManifest, serializeManifest } = require('./manifest');
25
+ const merger = require('./merger');
26
+ const { createBackup } = require('./backup');
27
+ const { createWriter } = require('./writer');
28
+ const { detectClient } = require('./client-detect');
29
+ const { detectV1, v1Hashes, v1AdapterText } = require('./v1-detect');
30
+ const { CliError } = require('./log');
31
+ const { version: PKG_VERSION } = require('../../package.json');
32
+
33
+ /** Files the package installs for `client` (skills + framework guide), excluding adapters and templates. */
34
+ function desiredFiles(client) {
35
+ const skills = listFiles(SKILLS_DIR);
36
+ const out = [];
37
+ for (const folder of CLIENT_FOLDERS[client]) {
38
+ for (const rel of skills) out.push({ path: `${folder}/skills/${rel}`, source: path.join(SKILLS_DIR, rel) });
39
+ }
40
+ out.push({ path: FRAMEWORK_PATH, source: FRAMEWORK_SOURCE });
41
+ return out;
42
+ }
43
+
44
+ function desiredAdapters(client) {
45
+ return CLIENT_ADAPTERS[client].map((name) => ({ path: name, source: path.join(ADAPTERS_DIR, name) }));
46
+ }
47
+
48
+ /** Templates from the package that are missing in project/. */
49
+ function missingTemplates(root) {
50
+ return listFiles(TEMPLATES_DIR).filter((tpl) => !fs.existsSync(resolveInside(root, `project/${tpl}`)));
51
+ }
52
+
53
+ function templateCopyHint(tpl, root) {
54
+ const quote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
55
+ return `cp ${quote(path.join(TEMPLATES_DIR, tpl))} ${quote(path.join(root, 'project', tpl))}`;
56
+ }
57
+
58
+ /**
59
+ * @param {{root: string, dryRun: boolean, client?: string, out: object}} ctx
60
+ * @param {{mode: 'init'|'update'}} options
61
+ * @returns {number} exit code
62
+ */
63
+ function sync(ctx, { mode }) {
64
+ const { root, dryRun, out } = ctx;
65
+ const existing = readManifest(root);
66
+ const v1 = existing ? null : detectV1(root);
67
+ const migrating = Boolean(v1 && v1.detected);
68
+
69
+ if (mode === 'update' && !existing && !migrating) {
70
+ throw new CliError(
71
+ 'Nenhuma instalação do Ignity encontrada nesta pasta (sem .ignity/manifest.json e sem sinais do v1). ' +
72
+ 'Para instalar, rode: npx @marvinribeiro_/ignity init',
73
+ );
74
+ }
75
+
76
+ const client = ctx.client || (existing && existing.client) || detectClient(root);
77
+ const previous = new Map(((existing && existing.files) || []).map((f) => [f.path, f]));
78
+ // Different client adapters cannot safely share one physical file: each has
79
+ // a different block, and updating or removing either would affect the other.
80
+ // Check before creating skills, backups, or any other files.
81
+ const adapters = new Set(desiredAdapters(client).map((a) => a.path));
82
+ for (const entry of previous.values()) if (entry.kind === 'merged-block') adapters.add(entry.path);
83
+ const seenAdapters = new Map();
84
+ for (const rel of adapters) {
85
+ const abs = resolveInside(root, rel);
86
+ if (!fs.existsSync(abs)) continue;
87
+ const stat = fs.statSync(abs);
88
+ const identity = `${stat.dev}:${stat.ino}`;
89
+ if (seenAdapters.has(identity)) {
90
+ throw new CliError(`${seenAdapters.get(identity)} e ${rel} apontam para o mesmo arquivo. Separe os arquivos antes de executar o Ignity novamente.`);
91
+ }
92
+ seenAdapters.set(identity, rel);
93
+ }
94
+ const baseline = migrating ? v1Hashes() : new Map([...previous.values()].filter((f) => !f.kind).map((f) => [f.path, f.sha256]));
95
+
96
+ out.line(`Ignity ${PKG_VERSION} — ${mode}${dryRun ? ' (simulação: nada será gravado)' : ''}`);
97
+ out.line(`Pasta: ${root}`);
98
+ out.line(`Cliente: ${client}${ctx.client ? '' : existing ? ' (do manifesto)' : ' (detectado)'}`);
99
+
100
+ if (migrating) {
101
+ out.line('Instalação v1 detectada (sem .ignity/manifest.json). Migrando para o layout v2.');
102
+ for (const l of v1.describe()) out.line(` ${l}`);
103
+ if (!v1.skillSignals.length) {
104
+ out.warn(`skills v1 não encontradas; as skills serão instaladas do zero em ${CLIENT_FOLDERS[client].join(' e ')}.`);
105
+ }
106
+ }
107
+
108
+ const writer = createWriter({ root, dryRun, out, allowProjectCreate: mode === 'init' });
109
+ const backup = createBackup({ root, dryRun, out });
110
+ const entries = [];
111
+ const desired = desiredFiles(client);
112
+ const desiredPaths = new Set(desired.map((d) => d.path));
113
+
114
+ // Skills and framework guide.
115
+ for (const d of desired) {
116
+ const data = fs.readFileSync(d.source);
117
+ const hash = sha256(data);
118
+ const abs = resolveInside(root, d.path);
119
+ const prev = previous.get(d.path);
120
+ let created;
121
+ if (!fs.existsSync(abs)) {
122
+ writer.create(d.path, data);
123
+ created = prev ? prev.created : true;
124
+ } else {
125
+ const current = sha256File(abs);
126
+ const known = baseline.get(d.path);
127
+ created = prev ? prev.created : Boolean(known);
128
+ if (current !== hash) {
129
+ if (known && current === known) {
130
+ backup.save(d.path);
131
+ writer.update(d.path, data);
132
+ } else {
133
+ const copy = backup.save(d.path);
134
+ out.warn(`${d.path} foi alterado fora do Ignity; cópia salva em ${copy} e arquivo substituído pela versão do pacote.`);
135
+ writer.update(d.path, data);
136
+ }
137
+ }
138
+ }
139
+ entries.push({ path: d.path, sha256: hash, owner: 'ignity', created });
140
+ }
141
+
142
+ // Files from a previous manifest that the package no longer installs (e.g. client narrowed).
143
+ for (const prev of previous.values()) {
144
+ if (prev.kind || desiredPaths.has(prev.path)) continue;
145
+ const abs = resolveInside(root, prev.path);
146
+ if (!fs.existsSync(abs)) continue;
147
+ if (sha256File(abs) === prev.sha256) writer.remove(prev.path, 'não faz mais parte da instalação');
148
+ else out.warn(`${prev.path} não faz mais parte da instalação, mas foi alterado; mantido no lugar.`);
149
+ }
150
+
151
+ // Adapter blocks.
152
+ const desiredAdapterPaths = new Set();
153
+ for (const a of desiredAdapters(client)) {
154
+ desiredAdapterPaths.add(a.path);
155
+ const body = fs.readFileSync(a.source, 'utf8');
156
+ const prev = previous.get(a.path);
157
+ const text = readIfExists(resolveInside(root, a.path), 'utf8');
158
+ let created = prev ? prev.created : false;
159
+ let next;
160
+ let note;
161
+ if (text === null) {
162
+ next = merger.upsertBlock(null, body);
163
+ created = true;
164
+ } else if (merger.hasBlock(text)) {
165
+ next = merger.upsertBlock(text, body);
166
+ } else if (migrating && sha256(text) === (v1Hashes().get(a.path) || '')) {
167
+ next = merger.upsertBlock(null, body);
168
+ created = true;
169
+ note = 'adapter v1 trocado pelo bloco com marcadores';
170
+ } else if (migrating && v1AdapterText(a.path) && merger.replaceTextWithBlock(text, v1AdapterText(a.path), body) !== null) {
171
+ next = merger.replaceTextWithBlock(text, v1AdapterText(a.path), body);
172
+ note = 'trecho v1 trocado pelo bloco com marcadores';
173
+ } else {
174
+ next = merger.upsertBlock(text, body);
175
+ note = 'bloco anexado ao final; conteúdo existente preservado';
176
+ if (migrating) out.warn(`${a.path} não contém o texto do adapter v1; o bloco do Ignity foi anexado ao final. Revise se há instruções duplicadas.`);
177
+ }
178
+ if (text === null) writer.create(a.path, next);
179
+ else if (next !== text) {
180
+ backup.save(a.path);
181
+ writer.update(a.path, next, note);
182
+ }
183
+ entries.push({ path: a.path, kind: 'merged-block', owner: 'ignity', created, blockSha256: sha256(merger.normalizeBody(body, '\n')) });
184
+ }
185
+
186
+ // Adapter blocks of a client that is no longer installed.
187
+ for (const prev of previous.values()) {
188
+ if (prev.kind !== 'merged-block' || desiredAdapterPaths.has(prev.path)) continue;
189
+ removeAdapterBlock({ root, rel: prev.path, created: prev.created, writer, backup });
190
+ }
191
+
192
+ // Templates in project/.
193
+ if (mode === 'init') {
194
+ for (const tpl of listFiles(TEMPLATES_DIR)) {
195
+ const rel = `project/${tpl}`;
196
+ if (!fs.existsSync(resolveInside(root, rel))) writer.create(rel, tpl === 'BRIEF.md' && ctx.brief ? ctx.brief : fs.readFileSync(path.join(TEMPLATES_DIR, tpl)));
197
+ }
198
+ } else {
199
+ for (const tpl of missingTemplates(root)) {
200
+ out.warn(`template project/${tpl} ausente. O update não toca project/; para copiá-lo: ${templateCopyHint(tpl, root)}`);
201
+ }
202
+ }
203
+
204
+ if (fs.existsSync(resolveInside(root, V1_FRAMEWORK_PATH))) {
205
+ out.line(`INFO: ${V1_FRAMEWORK_PATH} é da instalação v1 e ficou onde está (obsoleto). O guia atual está em ${FRAMEWORK_PATH}.`);
206
+ }
207
+
208
+ // Manifest (rewritten only when something changed, so a no-op run leaves no diff).
209
+ const now = new Date().toISOString();
210
+ const manifest = {
211
+ ignityVersion: PKG_VERSION,
212
+ client,
213
+ installedAt: (existing && existing.installedAt) || now,
214
+ updatedAt: now,
215
+ files: entries,
216
+ };
217
+ const unchanged =
218
+ existing &&
219
+ writer.changes === 0 &&
220
+ serializeManifest({ ...manifest, updatedAt: existing.updatedAt }) === serializeManifest(existing);
221
+ if (!unchanged) {
222
+ if (dryRun) out.line(`${existing ? 'PREVER ATUALIZAR' : 'PREVER CRIAR'} ${MANIFEST_PATH}`);
223
+ else {
224
+ writeManifest(root, manifest);
225
+ out.line(`${existing ? 'ATUALIZADO' : 'CRIADO'} ${MANIFEST_PATH}`);
226
+ }
227
+ }
228
+
229
+ out.line('');
230
+ if (writer.changes === 0 && unchanged) out.line('Nada a fazer: a instalação já está na versão do pacote.');
231
+ if (backup.count > 0 && !dryRun) out.line(`Backups em ${backup.dir}/`);
232
+ if (out.warnings.length) out.line(`${out.warnings.length} aviso(s) acima.`);
233
+ return 0;
234
+ }
235
+
236
+ /** Removes the Ignity block from an adapter; deletes the file if Ignity created it and it became empty. */
237
+ function removeAdapterBlock({ root, rel, created, writer, backup, blockSha256 }) {
238
+ const text = readIfExists(resolveInside(root, rel), 'utf8');
239
+ if (text === null || !merger.hasBlock(text)) return false;
240
+ const next = merger.removeBlock(text);
241
+ if (created && next.trim() === '') {
242
+ if (blockSha256 && sha256(merger.extractBody(text)) !== blockSha256) backup.save(rel);
243
+ writer.remove(rel, 'criado pelo Ignity e vazio sem o bloco');
244
+ } else {
245
+ backup.save(rel);
246
+ writer.update(rel, next, 'bloco do Ignity removido');
247
+ }
248
+ return true;
249
+ }
250
+
251
+ module.exports = { sync, desiredFiles, desiredAdapters, missingTemplates, templateCopyHint, removeAdapterBlock, PKG_VERSION };