@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,214 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * AC 16, 17, 17a: diagnostics. Each check is `{id, run(ctx) -> {level, message, fix}}`
5
+ * so later epics (G4) can plug more checks in. Only local files are read; the CLI
6
+ * never contacts the site. Exit 1 only when some check is FAIL.
7
+ */
8
+
9
+ const fs = require('node:fs');
10
+ const { checkNodeVersion } = require('../lib/node-check');
11
+ const { FRAMEWORK_PATH, V1_FRAMEWORK_PATH, CLIENT_FOLDERS, SKILLS_DIR, MANIFEST_PATH, resolveInside } = require('../lib/paths');
12
+ const { listFiles, sha256File, sha256, readIfExists } = require('../lib/files');
13
+ const { readManifest } = require('../lib/manifest');
14
+ const { extractBody } = require('../lib/merger');
15
+ const { detectV1 } = require('../lib/v1-detect');
16
+ const { detectClient } = require('../lib/client-detect');
17
+ const { readMcpServerNames } = require('../lib/mcp-config');
18
+ const { missingTemplates, templateCopyHint } = require('../lib/sync');
19
+
20
+ const LEVELS = ['OK', 'INFO', 'WARN', 'FAIL'];
21
+
22
+ const ELEMENTOR_PLAN_REQUIREMENT =
23
+ 'Requer Elementor Pro Advanced ou superior (Advanced Solo em diante). O plano Essential não tem Custom Code, usado pelo runtime da Ignity Motion.';
24
+
25
+ const nodeCheck = {
26
+ id: 'node',
27
+ run(ctx) {
28
+ const r = checkNodeVersion(ctx.nodeVersion || process.versions.node);
29
+ return r.ok
30
+ ? { level: 'OK', message: `Node.js ${r.found} (mínimo ${r.required}).` }
31
+ : {
32
+ level: 'FAIL',
33
+ message: `Node.js ${r.found || 'desconhecido'} abaixo do mínimo ${r.required}.`,
34
+ fix: 'Instale o Node 22 LTS ou mais novo em https://nodejs.org/ e rode o comando de novo.',
35
+ };
36
+ },
37
+ };
38
+
39
+ const manifestCheck = {
40
+ id: 'manifest',
41
+ run(ctx) {
42
+ if (ctx.manifestError) {
43
+ return {
44
+ level: 'FAIL',
45
+ message: ctx.manifestError,
46
+ fix: 'Restaure o arquivo a partir de um backup ou apague-o e rode `npx @marvinribeiro_/ignity update` (instalações v1 são migradas).',
47
+ };
48
+ }
49
+ if (ctx.manifest) return { level: 'OK', message: `${MANIFEST_PATH} válido (Ignity ${ctx.manifest.ignityVersion}, cliente ${ctx.manifest.client}).` };
50
+ if (ctx.v1.detected) {
51
+ return { level: 'WARN', message: 'Instalação v1 sem manifesto.', fix: 'Rode `npx @marvinribeiro_/ignity update` para migrar para o layout v2 (project/ não é tocado).' };
52
+ }
53
+ return { level: 'FAIL', message: `${MANIFEST_PATH} não encontrado.`, fix: 'Rode `npx @marvinribeiro_/ignity init` nesta pasta.' };
54
+ },
55
+ };
56
+
57
+ const integrityCheck = {
58
+ id: 'integrity',
59
+ run(ctx) {
60
+ if (!ctx.manifest) return { level: 'INFO', message: 'Sem manifesto: integridade não verificada.' };
61
+ const changed = [];
62
+ const missing = [];
63
+ for (const f of ctx.manifest.files) {
64
+ const abs = resolveInside(ctx.root, f.path);
65
+ if (f.kind === 'merged-block') {
66
+ const body = extractBody(readIfExists(abs, 'utf8'));
67
+ if (body === null) missing.push(`${f.path} (bloco)`);
68
+ else if (sha256(body) !== f.blockSha256) changed.push(`${f.path} (bloco)`);
69
+ } else if (!fs.existsSync(abs)) missing.push(f.path);
70
+ else if (sha256File(abs) !== f.sha256) changed.push(f.path);
71
+ }
72
+ if (!changed.length && !missing.length) return { level: 'OK', message: `${ctx.manifest.files.length} arquivo(s) do manifesto íntegros.` };
73
+ const parts = [];
74
+ if (changed.length) parts.push(`alterados: ${changed.join(', ')}`);
75
+ if (missing.length) parts.push(`ausentes: ${missing.join(', ')}`);
76
+ return { level: 'WARN', message: `Arquivos do Ignity divergem do manifesto (${parts.join('; ')}).`, fix: 'Rode `npx @marvinribeiro_/ignity update` (arquivos alterados vão para .ignity/backups/ antes da troca).' };
77
+ },
78
+ };
79
+
80
+ const skillsCheck = {
81
+ id: 'skills',
82
+ run(ctx) {
83
+ const expected = [...new Set(listFiles(SKILLS_DIR).map((rel) => rel.split('/')[0]))];
84
+ const lines = [];
85
+ let missingAny = false;
86
+ for (const folder of CLIENT_FOLDERS[ctx.client]) {
87
+ const found = expected.filter((s) => fs.existsSync(resolveInside(ctx.root, `${folder}/skills/${s}/SKILL.md`)));
88
+ if (found.length < expected.length) missingAny = true;
89
+ lines.push(`${folder}/skills: ${found.length}/${expected.length}`);
90
+ }
91
+ return missingAny
92
+ ? { level: 'WARN', message: `Skills incompletas (${lines.join('; ')}).`, fix: 'Rode `npx @marvinribeiro_/ignity update` para reinstalar as skills.' }
93
+ : { level: 'OK', message: `Skills encontradas (${lines.join('; ')}).` };
94
+ },
95
+ };
96
+
97
+ const templatesCheck = {
98
+ id: 'templates',
99
+ run(ctx) {
100
+ const missing = missingTemplates(ctx.root);
101
+ if (!missing.length) return { level: 'OK', message: 'Templates de project/ presentes.' };
102
+ return {
103
+ level: 'WARN',
104
+ message: `Templates ausentes em project/: ${missing.join(', ')}.`,
105
+ fix: `O update não toca project/. Para copiar: ${missing.map((tpl) => templateCopyHint(tpl, ctx.root)).join(' ; ')} (ou rode \`npx @marvinribeiro_/ignity init\`, que só cria templates ausentes).`,
106
+ };
107
+ },
108
+ };
109
+
110
+ const obsoleteFrameworkCheck = {
111
+ id: 'v1-framework',
112
+ run(ctx) {
113
+ if (!fs.existsSync(resolveInside(ctx.root, V1_FRAMEWORK_PATH))) return { level: 'OK', message: `Sem ${V1_FRAMEWORK_PATH} obsoleto.` };
114
+ return {
115
+ level: 'INFO',
116
+ message: `${V1_FRAMEWORK_PATH} é da instalação v1 e está obsoleto; o guia atual fica em ${FRAMEWORK_PATH}.`,
117
+ fix: 'Pode manter ou apagar o arquivo; o Ignity não remove nada de project/.',
118
+ };
119
+ },
120
+ };
121
+
122
+ function mcpCheck(id, label, pattern) {
123
+ return {
124
+ id,
125
+ run(ctx) {
126
+ const sources = readMcpServerNames(ctx.root, ctx.client, ctx.home);
127
+ const hit = sources.find((s) => s.names.some((n) => pattern.test(n)));
128
+ if (hit) return { level: 'OK', message: `Servidor MCP do ${label} declarado em ${hit.label}.` };
129
+ const read = sources.filter((s) => s.readable).map((s) => `${s.label}: ${s.names.length} servidor(es)`);
130
+ const unreadable = sources.filter((s) => s.exists && !s.readable).map((s) => s.label);
131
+ const seen = read.length ? ` Lidos: ${read.join('; ')}.` : ' Nenhuma configuração MCP encontrada.';
132
+ const bad = unreadable.length ? ` Não foi possível ler: ${unreadable.join(', ')}.` : '';
133
+ return {
134
+ level: 'WARN',
135
+ message: `Servidor MCP do ${label} não declarado.${seen}${bad}`,
136
+ fix: `Declare o servidor MCP do ${label} na configuração do cliente (${sources.map((s) => s.label).join(', ')}), com um nome que contenha "${label.toLowerCase()}". O Ignity não configura MCP nem acessa o site.`,
137
+ };
138
+ },
139
+ };
140
+ }
141
+
142
+ /**
143
+ * A9: Elementor Pro plan. Without verified evidence it is always INFO (never OK).
144
+ * `ctx.elementorEvidence` = `{customCodeAvailable: boolean}` is the contract for a future source (G4).
145
+ */
146
+ const elementorProPlanCheck = {
147
+ id: 'elementor-pro-plan',
148
+ run(ctx = {}) {
149
+ const evidence = ctx.elementorEvidence;
150
+ if (evidence && typeof evidence.customCodeAvailable === 'boolean') {
151
+ return evidence.customCodeAvailable
152
+ ? { level: 'OK', message: `${ELEMENTOR_PLAN_REQUIREMENT} Custom Code disponível.` }
153
+ : { level: 'FAIL', message: `${ELEMENTOR_PLAN_REQUIREMENT} Custom Code indisponível.`, fix: 'Faça upgrade para Elementor Pro Advanced ou superior.' };
154
+ }
155
+ return {
156
+ level: 'INFO',
157
+ message: ELEMENTOR_PLAN_REQUIREMENT,
158
+ fix: 'Confira o plano da sua licença na conta Elementor; se for Essential, faça upgrade para Advanced ou superior.',
159
+ };
160
+ },
161
+ };
162
+
163
+ const DEFAULT_CHECKS = [
164
+ nodeCheck,
165
+ manifestCheck,
166
+ integrityCheck,
167
+ skillsCheck,
168
+ templatesCheck,
169
+ obsoleteFrameworkCheck,
170
+ mcpCheck('mcp-elementor', 'Elementor', /elementor/i),
171
+ mcpCheck('mcp-figma', 'Figma', /figma/i),
172
+ elementorProPlanCheck,
173
+ ];
174
+
175
+ /** Runs checks; a throwing check becomes FAIL without echoing internal details. */
176
+ function runChecks(checks, ctx) {
177
+ return checks.map((check) => {
178
+ try {
179
+ const r = check.run(ctx);
180
+ if (!r || !LEVELS.includes(r.level)) throw new Error('resultado inválido');
181
+ return { id: check.id, ...r };
182
+ } catch (error) {
183
+ return { id: check.id, level: 'FAIL', message: `Check falhou: ${error.message}`, fix: 'Rode com IGNITY_DEBUG=1 para detalhes.' };
184
+ }
185
+ });
186
+ }
187
+
188
+ function buildContext(ctx) {
189
+ let manifest = null;
190
+ let manifestError = null;
191
+ try {
192
+ manifest = readManifest(ctx.root);
193
+ } catch (error) {
194
+ manifestError = error.message;
195
+ }
196
+ const client = ctx.client || (manifest && manifest.client) || detectClient(ctx.root);
197
+ return { ...ctx, manifest, manifestError, client, v1: detectV1(ctx.root) };
198
+ }
199
+
200
+ function doctor(ctx, checks = DEFAULT_CHECKS) {
201
+ const full = buildContext(ctx);
202
+ const results = runChecks(checks, full);
203
+ full.out.line(`Ignity doctor — ${full.root} (cliente: ${full.client})`);
204
+ for (const r of results) {
205
+ full.out.line(`[${r.level}] ${r.id}: ${r.message}`);
206
+ if (r.fix && r.level !== 'OK') full.out.line(` → ${r.fix}`);
207
+ }
208
+ const count = (lvl) => results.filter((r) => r.level === lvl).length;
209
+ full.out.line('');
210
+ full.out.line(`Resumo: ${count('OK')} OK, ${count('INFO')} INFO, ${count('WARN')} WARN, ${count('FAIL')} FAIL.`);
211
+ return count('FAIL') > 0 ? 1 : 0;
212
+ }
213
+
214
+ module.exports = { doctor, runChecks, DEFAULT_CHECKS, elementorProPlanCheck, nodeCheck, mcpCheck, ELEMENTOR_PLAN_REQUIREMENT };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { sync } = require('../lib/sync');
5
+ const { detectClient } = require('../lib/client-detect');
6
+ const { answers, ask } = require('../lib/prompt');
7
+ const { doctor } = require('./doctor');
8
+ const ELEMENTOR_REQUIREMENT = 'Requisito: Elementor Pro Advanced ou superior (Advanced Solo em diante); o plano Essential não tem Custom Code, usado pelo runtime da Ignity Motion.';
9
+ const NO_SITE_ACTION = 'A conexão MCP é configurada separadamente no cliente. O CLI não agiu no site.';
10
+ function finish(ctx, values) {
11
+ if (values) {
12
+ const template = fs.readFileSync(path.resolve(__dirname, '../../content/templates/BRIEF.md'), 'utf8');
13
+ ctx.brief = template.replace(/\{\{(\w+)\}\}/g, (_, key) => values[key]);
14
+ if (!fs.existsSync(path.resolve(ctx.root, values.assets))) ctx.out.warn('pasta não encontrada, crie e exporte os assets do Figma nela');
15
+ }
16
+ const code = sync(ctx, { mode: 'init' });
17
+ if (!ctx.dryRun) {
18
+ doctor({ ...ctx, out: { line: text => { if (/^\[(WARN|FAIL)\]/.test(text)) ctx.out.line(text); } } });
19
+ ctx.out.line(ELEMENTOR_REQUIREMENT);
20
+ ctx.out.line(NO_SITE_ACTION);
21
+ ctx.out.line(`Próximo passo: abra ${ctx.client === 'codex' ? 'o Codex e carregue a skill web-orchestrator' : ctx.client === 'both' ? 'o Claude Code e digite /web-orchestrator, ou carregue a skill web-orchestrator no Codex' : 'o Claude Code e digite /web-orchestrator'}`);
22
+ }
23
+ return code;
24
+ }
25
+ function init(ctx) {
26
+ ctx.client = ctx.client || detectClient(ctx.root);
27
+ ctx.out.line(`Detectado: ${ctx.client === 'claude' ? 'Claude Code (.claude/)' : ctx.client === 'codex' ? 'Codex (.agents/)' : 'Claude Code e Codex'} · ${['CLAUDE.md', 'AGENTS.md'].filter(f => fs.existsSync(path.join(ctx.root, f))).map(f => `${f} existente → mesclado`).join(' · ')}`);
28
+ if (fs.existsSync(path.join(ctx.root, 'project/BRIEF.md'))) {
29
+ ctx.out.line('BRIEF existente preservado');
30
+ return finish(ctx);
31
+ }
32
+ const flags = ctx.flags || {};
33
+ if (!ctx.dryRun && !flags.yes && process.stdin.isTTY && process.stdout.isTTY) return ask(flags, ctx.out).then(values => finish(ctx, values));
34
+ return finish(ctx, answers(flags));
35
+ }
36
+ module.exports = { init, ELEMENTOR_REQUIREMENT, NO_SITE_ACTION };
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const { resolveInside } = require('../lib/paths');
5
+ const { readManifest } = require('../lib/manifest');
6
+ const { detectV1 } = require('../lib/v1-detect');
7
+ const { PKG_VERSION } = require('../lib/sync');
8
+
9
+ /** Text under a `## {title}` heading, until the next `## `. */
10
+ function section(text, title) {
11
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
12
+ const start = lines.findIndex((l) => l.trim().toLowerCase() === `## ${title}`.toLowerCase());
13
+ if (start < 0) return null;
14
+ const body = [];
15
+ for (const l of lines.slice(start + 1)) {
16
+ if (/^##\s/.test(l)) break;
17
+ body.push(l);
18
+ }
19
+ return body;
20
+ }
21
+
22
+ /** Tolerant reader of the v1 STATE.md (Markdown): active goal and the Etapas table. */
23
+ function parseState(text) {
24
+ const goalLines = section(text, 'Objetivo ativo');
25
+ const goal = goalLines ? goalLines.map((l) => l.trim()).filter(Boolean).join(' ') : null;
26
+ const stages = [];
27
+ for (const l of section(text, 'Etapas') || []) {
28
+ if (!l.trim().startsWith('|')) continue;
29
+ const cells = l.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
30
+ if (cells.length < 2 || /^:?-+:?$/.test(cells[0]) || cells[0].toLowerCase() === 'etapa') continue;
31
+ stages.push({ stage: cells[0], state: cells[1] || '' });
32
+ }
33
+ return { goal, stages };
34
+ }
35
+
36
+ /** AC 18: read-only summary. */
37
+ function status(ctx) {
38
+ const { root, out } = ctx;
39
+ const manifest = readManifest(root);
40
+ out.line(`Pacote: ignity ${PKG_VERSION}`);
41
+ if (manifest) {
42
+ out.line(`Instalado: ${manifest.ignityVersion}${manifest.ignityVersion === PKG_VERSION ? '' : ' (rode `npx @marvinribeiro_/ignity update`)'}`);
43
+ out.line(`Cliente: ${manifest.client}`);
44
+ out.line(`Última atualização: ${manifest.updatedAt}`);
45
+ } else if (detectV1(root).detected) {
46
+ out.line('Instalado: v1 (sem manifesto). Rode `npx @marvinribeiro_/ignity update` para migrar.');
47
+ } else {
48
+ out.line('Instalado: não (rode `npx @marvinribeiro_/ignity init`).');
49
+ }
50
+
51
+ const stateFile = resolveInside(root, 'project/STATE.md');
52
+ if (!fs.existsSync(stateFile)) {
53
+ out.line('project/STATE.md não encontrado. Para começar, carregue /web-orchestrator no seu cliente.');
54
+ return 0;
55
+ }
56
+ const { goal, stages } = parseState(fs.readFileSync(stateFile, 'utf8'));
57
+ out.line('');
58
+ out.line(`Objetivo ativo: ${goal || '(não preenchido)'}`);
59
+ if (stages.length) {
60
+ out.line('Etapas:');
61
+ const width = Math.max(...stages.map((s) => s.stage.length));
62
+ for (const s of stages) out.line(` ${s.stage.padEnd(width)} ${s.state}`);
63
+ }
64
+ return 0;
65
+ }
66
+
67
+ module.exports = { status, parseState };
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { MANIFEST_PATH, IGNITY_DIR, BACKUPS_DIR, resolveInside, isProjectPath } = require('../lib/paths');
6
+ const { sha256File } = require('../lib/files');
7
+ const { readManifest } = require('../lib/manifest');
8
+ const { createBackup } = require('../lib/backup');
9
+ const { createWriter } = require('../lib/writer');
10
+ const { removeAdapterBlock } = require('../lib/sync');
11
+ const { detectV1 } = require('../lib/v1-detect');
12
+ const { CliError } = require('../lib/log');
13
+
14
+ /** AC 15: removes only what the manifest lists and is still unchanged. Never touches project/. */
15
+ function uninstall(ctx) {
16
+ const { root, dryRun, force, out } = ctx;
17
+ const manifest = readManifest(root);
18
+ if (!manifest) {
19
+ const hint = detectV1(root).detected
20
+ ? ' Há sinais de uma instalação v1: rode `npx @marvinribeiro_/ignity update` para migrar e depois `npx @marvinribeiro_/ignity uninstall`.'
21
+ : '';
22
+ throw new CliError(`Nada a remover: ${MANIFEST_PATH} não encontrado nesta pasta.${hint}`);
23
+ }
24
+
25
+ out.line(`Ignity — uninstall${dryRun ? ' (simulação: nada será removido)' : ''}`);
26
+ const writer = createWriter({ root, dryRun, out });
27
+ const backup = createBackup({ root, dryRun, out });
28
+ const kept = [];
29
+
30
+ for (const f of manifest.files) {
31
+ if (isProjectPath(f.path)) continue;
32
+ if (f.kind === 'merged-block') {
33
+ removeAdapterBlock({ root, rel: f.path, created: f.created, writer, backup, blockSha256: f.blockSha256 });
34
+ continue;
35
+ }
36
+ const abs = resolveInside(root, f.path);
37
+ if (!fs.existsSync(abs)) continue;
38
+ if (sha256File(abs) === f.sha256) writer.remove(f.path);
39
+ else if (force) {
40
+ backup.save(f.path);
41
+ writer.remove(f.path, 'alterado; removido por --force');
42
+ } else {
43
+ kept.push(f.path);
44
+ out.warn(`${f.path} foi alterado depois da instalação e foi mantido. Use --force para removê-lo (com backup).`);
45
+ }
46
+ }
47
+
48
+ // .ignity/ last; backups are preserved.
49
+ const ignityAbs = resolveInside(root, IGNITY_DIR);
50
+ if (fs.existsSync(ignityAbs)) {
51
+ if (dryRun) out.line(`PREVER REMOVER ${MANIFEST_PATH}`);
52
+ else {
53
+ fs.rmSync(resolveInside(root, MANIFEST_PATH), { force: true });
54
+ out.line(`REMOVIDO ${MANIFEST_PATH}`);
55
+ }
56
+ const backupsAbs = resolveInside(root, BACKUPS_DIR);
57
+ const hasBackups = fs.existsSync(backupsAbs) && fs.readdirSync(backupsAbs).length > 0;
58
+ if (hasBackups || (dryRun && backup.count > 0)) {
59
+ out.line(`Backups mantidos em ${path.posix.join(BACKUPS_DIR)}/`);
60
+ }
61
+ if (!dryRun && fs.readdirSync(ignityAbs).length === 0) {
62
+ fs.rmdirSync(ignityAbs);
63
+ } else if (!dryRun && !hasBackups) {
64
+ out.line('Arquivos restantes em .ignity/ foram mantidos.');
65
+ }
66
+ }
67
+
68
+ out.line('');
69
+ out.line('project/ não foi tocado.');
70
+ if (kept.length) out.line(`${kept.length} arquivo(s) alterado(s) mantido(s).`);
71
+ return 0;
72
+ }
73
+
74
+ module.exports = { uninstall };
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ const { sync } = require('../lib/sync');
4
+
5
+ /**
6
+ * AC 11-14: updates skills, `.ignity/framework/` and adapter blocks; migrates v1.
7
+ * Never creates, changes or removes anything in project/ (enforced by the writer guard).
8
+ */
9
+ function update(ctx) {
10
+ return sync(ctx, { mode: 'update' });
11
+ }
12
+
13
+ module.exports = { update };
@@ -0,0 +1,44 @@
1
+ {
2
+ "description": "Hashes SHA-256 dos 29 arquivos instalados pelo instalar.py v1 (modo both) e texto original dos adapters v1. Copiado de test/fixtures/v1-manifest.json (Story 1.2).",
3
+ "files": {
4
+ ".agents/skills/elementor/SKILL.md": "d02ff39a656da6298d1070c7f5ea116e5d9ccaadf17a54417137f60021997786",
5
+ ".agents/skills/elementor/references/operacao.md": "4eb60cd80b4a65282b377449867a5fd168f7e198d173aa6f88f2c2285e98a6b7",
6
+ ".agents/skills/web-assets/SKILL.md": "322744840caf94539c3b5a1080753255037bbacb46b1fff91e6b249254e53049",
7
+ ".agents/skills/web-figma-spec/SKILL.md": "5a0029f9d66c922cd52df61f3a33779549289aa0aa37c489fc59b608feb94564",
8
+ ".agents/skills/web-motion/SKILL.md": "9ed2022909b01806b43a9c41eddec88193f09fbc0c1ce5945fefe4d8794fa9f1",
9
+ ".agents/skills/web-orchestrator/SKILL.md": "057fd27edc2726b418995ad519459700a6e9265d36955522fadceb0a519b8e56",
10
+ ".agents/skills/web-qa/SKILL.md": "6a6b5f30c45645490a33c6c257b572c1bef54ec2ac37f4e42986a06584f0e931",
11
+ ".agents/skills/web-release/SKILL.md": "e72e8dbbb793cb450f4e79800c855966f914fa520da6fb981621509c7660f315",
12
+ ".agents/skills/web-seo/SKILL.md": "b9906bcba3064de7d8b87749e9305b3bdbd092b2d9dc5c4e92f182bc9bc5eb34",
13
+ ".claude/skills/elementor/SKILL.md": "d02ff39a656da6298d1070c7f5ea116e5d9ccaadf17a54417137f60021997786",
14
+ ".claude/skills/elementor/references/operacao.md": "4eb60cd80b4a65282b377449867a5fd168f7e198d173aa6f88f2c2285e98a6b7",
15
+ ".claude/skills/web-assets/SKILL.md": "322744840caf94539c3b5a1080753255037bbacb46b1fff91e6b249254e53049",
16
+ ".claude/skills/web-figma-spec/SKILL.md": "5a0029f9d66c922cd52df61f3a33779549289aa0aa37c489fc59b608feb94564",
17
+ ".claude/skills/web-motion/SKILL.md": "9ed2022909b01806b43a9c41eddec88193f09fbc0c1ce5945fefe4d8794fa9f1",
18
+ ".claude/skills/web-orchestrator/SKILL.md": "057fd27edc2726b418995ad519459700a6e9265d36955522fadceb0a519b8e56",
19
+ ".claude/skills/web-qa/SKILL.md": "6a6b5f30c45645490a33c6c257b572c1bef54ec2ac37f4e42986a06584f0e931",
20
+ ".claude/skills/web-release/SKILL.md": "e72e8dbbb793cb450f4e79800c855966f914fa520da6fb981621509c7660f315",
21
+ ".claude/skills/web-seo/SKILL.md": "b9906bcba3064de7d8b87749e9305b3bdbd092b2d9dc5c4e92f182bc9bc5eb34",
22
+ "AGENTS.md": "431cecd1ef9f68c2d0ffa39c0ddbac6f71bbe9635a75d92951041dd0c0676072",
23
+ "CLAUDE.md": "1456b0bc41cc0bf8baf9cf38c18c170e999316e2a8d222c48886b5ca0049eee6",
24
+ "project/ASSETS.csv": "e832759924d6c484237f411754cad43f2c413b0b0f794850f9c80a7db8ae0429",
25
+ "project/BRIEF.md": "7d4a165dc55875cb3c1c8b5ab0cc3d7973b6e9191db0cb8c0d1230af3339c62b",
26
+ "project/DECISIONS.md": "e63a4eb92651c3a0c319fca0d7e2f14bbd8d5db48cdb074f8b7ba1d7ffd00a19",
27
+ "project/INICIAR.md": "892f5911925982a052348763dd1b4dd79f981e6f2234d3ba994734fb9c6424a0",
28
+ "project/QA.md": "a392fd4087c2f774fe20e157c327a3b969f1e9928efc3086a92f1026dc073513",
29
+ "project/RETOMAR.md": "3d39da759ab4124c462acbdbef7f5deaa7d5229636f072dce7683b3a14137dce",
30
+ "project/SECTIONS.md": "99f8e4d905b464139bb3e5b3ae54306d1b5381a6b6744c5a6e6383c0471aa88f",
31
+ "project/STATE.md": "d8a1001afd77551745720733788d6cee9c38a5e0ea471f3710dca571786237d5",
32
+ "project/framework/FRAMEWORK.md": "e7db2f4e18069b96b42178e4dc008792cdae564e0ddb5dd1666f84d5a43a99fa"
33
+ },
34
+ "adapters": {
35
+ "CLAUDE.md": {
36
+ "sha256": "1456b0bc41cc0bf8baf9cf38c18c170e999316e2a8d222c48886b5ca0049eee6",
37
+ "text": "# Orientações do projeto web\n\nEste projeto usa o Framework Agêntico Web. Para iniciar ou retomar o fluxo completo, carregar `web-orchestrator` em `.claude/skills/web-orchestrator/SKILL.md`, disponível também como `/web-orchestrator` no Claude Code. Para uma alteração pontual, ler o estado e carregar apenas os módulos pertinentes.\n\nContexto: `project/BRIEF.md`. Continuidade: `project/STATE.md`. Decisões: `project/DECISIONS.md`. Referências e QA: demais arquivos em `project/`. Guia: `project/framework/FRAMEWORK.md`.\n\nPreservar conteúdo nativo e editável no Elementor, reutilizar componentes e conferir desktop/mobile. Descobrir o MCP e o schema reais, ler a revisão atual antes de editar e verificar persistência. Não transportar IDs, acessos ou decisões de outra marca. Priorizar o pedido atual e as autorizações existentes; o framework não concede permissões adicionais.\n\nNão abrir agentes paralelos automaticamente. Registrar evidência e próximo passo ao encerrar. Não salvar segredos ou URLs assinadas nos registros. Se estas orientações forem adicionadas a um projeto existente, mesclar com suas instruções sem apagar regras anteriores.\n"
38
+ },
39
+ "AGENTS.md": {
40
+ "sha256": "431cecd1ef9f68c2d0ffa39c0ddbac6f71bbe9635a75d92951041dd0c0676072",
41
+ "text": "# Orientações do projeto web\n\nEste projeto usa o Framework Agêntico Web. Para iniciar ou retomar o fluxo completo, carregar `web-orchestrator` em `.agents/skills/web-orchestrator/SKILL.md`. Para uma alteração pontual, ler o estado e carregar apenas os módulos pertinentes.\n\nContexto: `project/BRIEF.md`. Continuidade: `project/STATE.md`. Decisões: `project/DECISIONS.md`. Referências e QA: demais arquivos em `project/`. Guia: `project/framework/FRAMEWORK.md`.\n\nPreservar conteúdo nativo e editável no Elementor, reutilizar componentes e conferir desktop/mobile. Descobrir o MCP e o schema reais, ler a revisão atual antes de editar e verificar persistência. Não transportar IDs, acessos ou decisões de outra marca. Priorizar o pedido atual e as autorizações existentes; o framework não concede permissões adicionais.\n\nNão abrir agentes paralelos automaticamente. Registrar evidência e próximo passo ao encerrar. Não salvar segredos ou URLs assinadas nos registros. Se estas orientações forem adicionadas a um projeto existente, mesclar com suas instruções sem apagar regras anteriores.\n"
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ /** AC 9: copies a file to .ignity/backups/{timestamp}/{path} before it is changed or removed. */
4
+
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const { BACKUPS_DIR, resolveInside } = require('./paths');
8
+
9
+ function timestamp(date = new Date()) {
10
+ return date.toISOString().replace(/[:.]/g, '-');
11
+ }
12
+
13
+ /**
14
+ * One backup folder per run, created lazily on the first saved file.
15
+ * @param {{root: string, dryRun: boolean, out: object, now?: Date}} options
16
+ */
17
+ function createBackup({ root, dryRun, out, now = new Date() }) {
18
+ const dir = `${BACKUPS_DIR}/${timestamp(now)}`;
19
+ let count = 0;
20
+ return {
21
+ dir,
22
+ get count() {
23
+ return count;
24
+ },
25
+ /** @returns {string} relative path of the backup copy */
26
+ save(rel) {
27
+ const target = `${dir}/${rel}`;
28
+ if (dryRun) {
29
+ out.line(`PREVER CRIAR ${target} (backup)`);
30
+ } else {
31
+ const dest = resolveInside(root, target);
32
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
33
+ const source = resolveInside(root, rel);
34
+ const mode = fs.statSync(source).mode & 0o7777;
35
+ // Create or tighten the destination before copying private contents.
36
+ const fd = fs.openSync(dest, 'w', 0o600);
37
+ try {
38
+ fs.fchmodSync(fd, 0o600);
39
+ } finally {
40
+ fs.closeSync(fd);
41
+ }
42
+ fs.copyFileSync(source, dest);
43
+ fs.chmodSync(dest, mode);
44
+ out.line(`BACKUP ${rel} -> ${target}`);
45
+ }
46
+ count++;
47
+ return target;
48
+ },
49
+ };
50
+ }
51
+
52
+ module.exports = { createBackup, timestamp };
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ /**
7
+ * AC 3: `.claude/` -> claude; `.agents/` or `AGENTS.md` -> codex; both -> both; none -> both (v1 default).
8
+ * @param {string} root
9
+ * @returns {'claude'|'codex'|'both'}
10
+ */
11
+ function detectClient(root) {
12
+ const claude = fs.existsSync(path.join(root, '.claude'));
13
+ const codex = fs.existsSync(path.join(root, '.agents')) || fs.existsSync(path.join(root, 'AGENTS.md'));
14
+ if (claude && !codex) return 'claude';
15
+ if (codex && !claude) return 'codex';
16
+ return 'both';
17
+ }
18
+
19
+ module.exports = { detectClient };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * File helpers used at runtime by the CLI. Mirrors scripts/lib/fs-utils.js
5
+ * (listFiles/sha256), which is not shipped in the npm package (`files` in
6
+ * package.json), plus atomic writes.
7
+ */
8
+
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const crypto = require('node:crypto');
12
+
13
+ /** Every regular file under `root` as sorted POSIX relative paths. Symlinks are skipped. */
14
+ function listFiles(root) {
15
+ const out = [];
16
+ (function walk(dir) {
17
+ for (const dirent of fs.readdirSync(dir, { withFileTypes: true })) {
18
+ const abs = path.join(dir, dirent.name);
19
+ if (dirent.isDirectory()) walk(abs);
20
+ else if (dirent.isFile()) out.push(path.relative(root, abs).split(path.sep).join('/'));
21
+ }
22
+ })(root);
23
+ return out.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
24
+ }
25
+
26
+ function sha256(data) {
27
+ return crypto.createHash('sha256').update(data).digest('hex');
28
+ }
29
+
30
+ function sha256File(file) {
31
+ return sha256(fs.readFileSync(file));
32
+ }
33
+
34
+ /** Returns the file content (Buffer, or string if `encoding`) or null if it does not exist. */
35
+ function readIfExists(file, encoding) {
36
+ try {
37
+ return fs.readFileSync(file, encoding);
38
+ } catch (error) {
39
+ if (error.code === 'ENOENT') return null;
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ /** Writes via a temp file in the same directory + rename, so readers never see a partial file. */
45
+ function atomicWriteFile(file, data) {
46
+ fs.mkdirSync(path.dirname(file), { recursive: true });
47
+ // Rename over a symlink would replace the link itself. The caller validates
48
+ // its destination inside the project; write atomically to that destination.
49
+ let target = file;
50
+ try {
51
+ if (fs.lstatSync(file).isSymbolicLink()) target = fs.realpathSync(file);
52
+ } catch (error) {
53
+ if (error.code !== 'ENOENT' || fs.existsSync(file)) throw error;
54
+ // A dangling link is not a new file and must never be silently replaced.
55
+ try {
56
+ if (fs.lstatSync(file).isSymbolicLink()) throw new Error('Link simbólico sem destino recusado.');
57
+ } catch (inner) {
58
+ if (inner.code !== 'ENOENT') throw inner;
59
+ }
60
+ }
61
+ const tmp = path.join(path.dirname(target), `.${path.basename(target)}.ignity-tmp-${process.pid}`);
62
+ try {
63
+ let mode;
64
+ try {
65
+ mode = fs.statSync(target).mode & 0o7777;
66
+ } catch (error) {
67
+ if (error.code !== 'ENOENT') throw error;
68
+ mode = 0o666 & ~process.umask();
69
+ }
70
+ // Keep the temporary file private until its final permissions are set.
71
+ const fd = fs.openSync(tmp, 'w', 0o600);
72
+ try {
73
+ fs.fchmodSync(fd, 0o600);
74
+ fs.writeFileSync(fd, data);
75
+ fs.fchmodSync(fd, mode);
76
+ } finally {
77
+ fs.closeSync(fd);
78
+ }
79
+ fs.renameSync(tmp, target);
80
+ } catch (error) {
81
+ fs.rmSync(tmp, { force: true });
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ module.exports = { listFiles, sha256, sha256File, readIfExists, atomicWriteFile };
package/src/lib/log.js ADDED
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Output helper. Every line the CLI prints goes through here; callers only pass
5
+ * paths, counts and fixed pt-BR messages — never environment values or config contents (Q-04).
6
+ */
7
+
8
+ class CliError extends Error {
9
+ constructor(message, exitCode = 1) {
10
+ super(message);
11
+ this.name = 'CliError';
12
+ this.exitCode = exitCode;
13
+ }
14
+ }
15
+
16
+ function createOutput({ stdout = process.stdout, stderr = process.stderr } = {}) {
17
+ const warnings = [];
18
+ return {
19
+ warnings,
20
+ line(text = '') {
21
+ stdout.write(`${text}\n`);
22
+ },
23
+ warn(text) {
24
+ warnings.push(text);
25
+ stdout.write(`AVISO: ${text}\n`);
26
+ },
27
+ error(text) {
28
+ stderr.write(`${text}\n`);
29
+ },
30
+ };
31
+ }
32
+
33
+ /** Collects output in memory (unit tests). */
34
+ function createMemoryOutput() {
35
+ const chunks = { stdout: '', stderr: '' };
36
+ const out = createOutput({
37
+ stdout: { write: (s) => (chunks.stdout += s) },
38
+ stderr: { write: (s) => (chunks.stderr += s) },
39
+ });
40
+ out.text = chunks;
41
+ return out;
42
+ }
43
+
44
+ module.exports = { CliError, createOutput, createMemoryOutput };