jarvis-ai-framework 3.2.0 → 4.0.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.
- package/agents/product/prod.discovery-interviewer.md +1 -1
- package/bin/README.md +14 -14
- package/bin/commands/docs-publish.js +50 -92
- package/bin/commands/docs-sync.js +30 -63
- package/bin/lib/cli/program.js +23 -25
- package/bin/lib/docs/fetch-file.js +29 -26
- package/bin/lib/docs/publish-file.js +89 -161
- package/bin/lib/docs/storage/build-index.js +87 -0
- package/bin/lib/docs/storage/filesystem.js +203 -0
- package/bin/lib/docs/storage/index.js +75 -0
- package/bin/lib/docs/validate-frontmatter.js +14 -4
- package/bin/lib/vcs/api.js +0 -125
- package/package.json +2 -2
- package/rules/AGENTS.md +12 -9
- package/rules/engineering/eng.integrations-rules.md +2 -1
- package/rules/product/prod-rules.md +4 -5
- package/rules-on-demand/product/prod.guided-rules.md +104 -0
- package/skills/jarvis-docs-central/README.md +57 -269
- package/skills/jarvis-docs-central/SKILL.md +126 -296
- package/skills/jarvis-init/SKILL.md +46 -7
- package/templates/ENV-template.md +20 -5
- package/templates/engineering/ARD-template.md +39 -6
- package/templates/engineering/architecture-template.md +1 -0
- package/templates/product/prod-breakdown-template.md +20 -2
- package/templates/product/prod-discovery-template.md +24 -1
- package/templates/product/prod-epic-template.md +32 -1
- package/templates/product/prod-frd-template.md +29 -2
- package/templates/product/prod-issue-template.md +36 -1
- package/templates/product/prod-prd-template.md +33 -2
- package/workflows/engineering/eng.build-tech-spec.md +2 -2
- package/workflows/engineering/eng.create-ard-from-code.md +9 -11
- package/workflows/engineering/eng.create-ard.md +15 -13
- package/workflows/engineering/eng.create-rfc.md +8 -10
- package/workflows/engineering/eng.pr.md +4 -4
- package/workflows/engineering/eng.pre-pr.md +4 -4
- package/workflows/engineering/eng.rpa.robot.md +4 -2
- package/workflows/engineering/eng.start.md +17 -16
- package/workflows/product/prod.roadmap.preview.md +4 -1
- package/workflows/product/prod.spec.ard.md +7 -3
- package/workflows/product/prod.spec.breakdown.md +7 -3
- package/workflows/product/prod.spec.clarify.md +5 -1
- package/workflows/product/prod.spec.discovery.md +7 -3
- package/workflows/product/prod.spec.epic.md +13 -11
- package/workflows/product/prod.spec.frd.md +15 -13
- package/workflows/product/prod.spec.issue.md +5 -1
- package/workflows/product/prod.spec.md +3 -1
- package/workflows/product/prod.spec.prd.md +15 -13
- package/workflows/warm-up.md +3 -3
- package/bin/lib/docs/redis-cache.js +0 -49
- package/bin/lib/vcs/fetch-raw.js +0 -42
|
@@ -1,194 +1,122 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Publica um documento no
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* @fileoverview Publica um documento no Central docs pelo adaptador configurado
|
|
3
|
+
* (`CENTRAL_DOCS_STORAGE`). O destino segue a estrutura única
|
|
4
|
+
* `{SQUAD}/{product|engineering}/{produto}/{arquivo}` e o índice do squad é
|
|
5
|
+
* regenerado a cada publicação (ver `storage/build-index.js`).
|
|
5
6
|
* @module docs/publish-file
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
-
import { validateFrontmatter
|
|
10
|
-
import { commitFiles, createMergeRequest } from "../vcs/api.js";
|
|
11
|
-
import { fetchFile } from "./fetch-file.js";
|
|
12
|
-
import { redisDel } from "./redis-cache.js";
|
|
9
|
+
import { basename, extname } from "node:path";
|
|
10
|
+
import { validateFrontmatter } from "./validate-frontmatter.js";
|
|
13
11
|
import { logger } from "../utils/logger.js";
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return { destPath, area: "engineering" };
|
|
40
|
-
}
|
|
41
|
-
if (tipo === "qa-report") {
|
|
42
|
-
return { destPath: `${base}/engineering/qa/qa-report-${feature}.md`, area: "engineering" };
|
|
13
|
+
/** @constant {Readonly<Record<string, "product"|"engineering">>} Área de cada tipo */
|
|
14
|
+
export const AREA_BY_TYPE = Object.freeze({
|
|
15
|
+
prd: "product",
|
|
16
|
+
frd: "product",
|
|
17
|
+
feat: "product",
|
|
18
|
+
epic: "product",
|
|
19
|
+
story: "product",
|
|
20
|
+
task: "product",
|
|
21
|
+
ard: "engineering",
|
|
22
|
+
rfc: "engineering",
|
|
23
|
+
swagger: "engineering",
|
|
24
|
+
"qa-report": "engineering",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** @constant {ReadonlyArray<string>} Tipos aceitos pelo `jarvis docs publish` */
|
|
28
|
+
export const VALID_TYPES = Object.freeze(Object.keys(AREA_BY_TYPE));
|
|
29
|
+
|
|
30
|
+
const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
31
|
+
|
|
32
|
+
function assertSegment(label, value) {
|
|
33
|
+
if (!value || !SEGMENT.test(value)) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`${label} inválido: "${value ?? ""}" (use letras, números, "-" ou "_", sem "/" nem "..")`
|
|
36
|
+
);
|
|
43
37
|
}
|
|
44
|
-
|
|
45
|
-
throw new Error(`Tipo inválido: ${tipo}\nTipos válidos: ${VALID_TYPES.join(", ")}`);
|
|
46
38
|
}
|
|
47
39
|
|
|
48
40
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* @param {string}
|
|
56
|
-
* @param {
|
|
57
|
-
* @param {string}
|
|
58
|
-
* @
|
|
41
|
+
* Calcula o destino do documento no Central docs.
|
|
42
|
+
* O nome é o do arquivo local quando ele já segue `{tipo}-…` (ex: `prd-001-checkout.md`);
|
|
43
|
+
* senão vem de `--feature` (`{tipo}-{feature}.{ext}`).
|
|
44
|
+
*
|
|
45
|
+
* @param {Object} p
|
|
46
|
+
* @param {string} p.tipo
|
|
47
|
+
* @param {string} p.squad
|
|
48
|
+
* @param {string} p.produto - Padrão: WORKSPACE (o caller resolve)
|
|
49
|
+
* @param {string} p.localFile
|
|
50
|
+
* @param {string} [p.feature]
|
|
51
|
+
* @returns {{ destPath: string, area: "product"|"engineering", fileName: string }}
|
|
59
52
|
*/
|
|
60
|
-
export function
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
53
|
+
export function resolveDestPath({ tipo, squad, produto, localFile, feature }) {
|
|
54
|
+
const area = AREA_BY_TYPE[tipo];
|
|
55
|
+
if (!area) {
|
|
56
|
+
throw new Error(`Tipo inválido: ${tipo}\nTipos válidos: ${VALID_TYPES.join(", ")}`);
|
|
57
|
+
}
|
|
58
|
+
assertSegment("Squad", squad);
|
|
59
|
+
assertSegment("Produto", produto);
|
|
60
|
+
|
|
61
|
+
const local = basename(localFile);
|
|
62
|
+
const ext = extname(local).toLowerCase() || ".md";
|
|
63
|
+
const followsPattern = local.toLowerCase().startsWith(`${tipo}-`) && /\.(md|ya?ml|json)$/.test(ext);
|
|
64
|
+
|
|
65
|
+
let fileName;
|
|
66
|
+
if (followsPattern) {
|
|
67
|
+
fileName = local;
|
|
68
|
+
} else if (feature) {
|
|
69
|
+
assertSegment("Feature", feature);
|
|
70
|
+
fileName = `${tipo}-${feature}${ext}`;
|
|
71
|
+
} else {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`O arquivo "${local}" não segue o padrão {tipo}-{id}-{nome}${ext}.\n` +
|
|
74
|
+
`Renomeie o arquivo ou informe --feature <slug>.`
|
|
75
|
+
);
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
|
|
73
|
-
return { content: `${trimmed}\n\n## ${area}\n${newLine}\n`, found: false };
|
|
78
|
+
return { destPath: `${squad}/${area}/${produto}/${fileName}`, area, fileName };
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
/**
|
|
77
82
|
* @param {Object} opts
|
|
78
|
-
* @param {
|
|
79
|
-
* @param {string} opts.
|
|
80
|
-
* @param {string} opts.
|
|
81
|
-
* @param {string} opts.squad
|
|
82
|
-
* @param {string} opts.
|
|
83
|
-
* @param {string} opts.
|
|
84
|
-
* @
|
|
85
|
-
* @param {string|number} [opts.cacheTtl] - TTL do cache do index.md em segundos (padrão: 3600)
|
|
86
|
-
* @returns {Promise<{ sha: string, mr: { vendor: string, iid: number|string, url: string } }>}
|
|
83
|
+
* @param {Object} opts.storage - Adaptador (`getStorage`)
|
|
84
|
+
* @param {string} opts.localFile
|
|
85
|
+
* @param {string} opts.tipo
|
|
86
|
+
* @param {string} opts.squad
|
|
87
|
+
* @param {string} opts.produto
|
|
88
|
+
* @param {string} [opts.feature]
|
|
89
|
+
* @returns {Promise<{ destPath: string, indexPath: string, sha?: string, branch?: string, mr?: Object }>}
|
|
87
90
|
*/
|
|
88
|
-
export async function publishFile(
|
|
89
|
-
|
|
90
|
-
localFile,
|
|
91
|
-
tipo,
|
|
92
|
-
feature,
|
|
93
|
-
squad,
|
|
94
|
-
workspace,
|
|
95
|
-
repo,
|
|
96
|
-
targetBranch = "dev",
|
|
97
|
-
cacheTtl = "3600",
|
|
98
|
-
} = opts;
|
|
99
|
-
|
|
100
|
-
if (!repo) {
|
|
91
|
+
export async function publishFile({ storage, localFile, tipo, squad, produto, feature }) {
|
|
92
|
+
if (!storage) {
|
|
101
93
|
throw new Error(
|
|
102
|
-
"
|
|
94
|
+
"Central docs desligado (CENTRAL_DOCS_STORAGE vazio). Configure-o no /jarvis-init."
|
|
103
95
|
);
|
|
104
96
|
}
|
|
105
97
|
|
|
106
|
-
|
|
107
|
-
validateFrontmatter(localFile, tipo);
|
|
108
|
-
logger.info("✅ Frontmatter válido");
|
|
109
|
-
|
|
110
|
-
const metadata = extractMetadata(localFile);
|
|
111
|
-
const version = metadata.version || "1.0.0";
|
|
112
|
-
const status = metadata.status || "draft";
|
|
113
|
-
const jira = metadata.jira || "";
|
|
114
|
-
const docName = metadata.name || "";
|
|
98
|
+
const { destPath } = resolveDestPath({ tipo, squad, produto, localFile, feature });
|
|
115
99
|
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
100
|
+
const isStructuredSwagger = tipo === "swagger" && !localFile.toLowerCase().endsWith(".md");
|
|
101
|
+
if (!isStructuredSwagger) {
|
|
102
|
+
logger.info("Validando frontmatter...");
|
|
103
|
+
validateFrontmatter(localFile, tipo);
|
|
104
|
+
logger.info("✅ Frontmatter válido");
|
|
105
|
+
}
|
|
119
106
|
|
|
120
107
|
logger.info("");
|
|
121
108
|
logger.info("📋 Resumo da Publicação:");
|
|
122
|
-
logger.info(`
|
|
123
|
-
logger.info(` Workspace: ${workspace}`);
|
|
124
|
-
logger.info(` Tipo: ${tipo}`);
|
|
125
|
-
logger.info(` Feature: ${feature}`);
|
|
109
|
+
logger.info(` Armazenamento: ${storage.kind}${storage.isGit ? " (git)" : ""}`);
|
|
126
110
|
logger.info(` Destino: ${destPath}`);
|
|
127
|
-
logger.info(` Branch: ${branchName} → ${targetBranch}`);
|
|
128
111
|
logger.info("");
|
|
129
112
|
|
|
130
|
-
const
|
|
113
|
+
const result = await storage.publish({ localFile, destPath, squad, tipo });
|
|
131
114
|
|
|
132
|
-
logger.info(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
} catch {
|
|
137
|
-
indexContent = "";
|
|
115
|
+
logger.info(`✅ Publicado em ${destPath}`);
|
|
116
|
+
logger.info(`🗂️ Índice regenerado: ${result.indexPath}`);
|
|
117
|
+
if (result.mr) {
|
|
118
|
+
logger.info(`🔀 Pull/Merge Request: ${result.mr.url}`);
|
|
138
119
|
}
|
|
139
120
|
|
|
140
|
-
|
|
141
|
-
if (!indexContent) {
|
|
142
|
-
logger.info("⚠️ index.md não encontrado, será criado");
|
|
143
|
-
indexAction = "create";
|
|
144
|
-
indexContent = `# ${workspace}\n\n**Squad:** ${squad}\n\n## ${area}\n\n`;
|
|
145
|
-
} else {
|
|
146
|
-
indexAction = "update";
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
let newLine = `- [${tipo}-${feature}.md](${area}/${tipo}-${feature}.md) | v${version} | ${status}`;
|
|
150
|
-
if (jira) newLine += ` | jira: ${jira}`;
|
|
151
|
-
if (docName) newLine += ` | ${docName}`;
|
|
152
|
-
|
|
153
|
-
const { content: updatedIndex } = insertIndexLine(indexContent, area, newLine);
|
|
154
|
-
const updatedIndexBase64 = Buffer.from(updatedIndex, "utf-8").toString("base64");
|
|
155
|
-
|
|
156
|
-
logger.info("Criando branch e commit...");
|
|
157
|
-
const commitResult = await commitFiles(repo, {
|
|
158
|
-
branch: branchName,
|
|
159
|
-
startBranch: targetBranch,
|
|
160
|
-
message: `docs(${squad}): add ${tipo} ${feature}`,
|
|
161
|
-
files: [
|
|
162
|
-
{ path: destPath, contentBase64: fileContentBase64, action: "create" },
|
|
163
|
-
{ path: indexPath, contentBase64: updatedIndexBase64, action: indexAction },
|
|
164
|
-
],
|
|
165
|
-
});
|
|
166
|
-
logger.info(`✅ Commit criado: ${commitResult.sha}`);
|
|
167
|
-
|
|
168
|
-
logger.info("Criando Merge/Pull Request...");
|
|
169
|
-
let mrDescription = `## Documento\n\n- **Tipo**: ${tipo}\n- **Workspace**: ${workspace}\n- **Feature**: ${feature}`;
|
|
170
|
-
if (jira) mrDescription += `\n- **Card**: ${jira}`;
|
|
171
|
-
if (docName) mrDescription += `\n- **Nome**: ${docName}`;
|
|
172
|
-
|
|
173
|
-
const mr = await createMergeRequest(repo, {
|
|
174
|
-
source: branchName,
|
|
175
|
-
target: targetBranch,
|
|
176
|
-
title: `docs(${squad}): ${tipo} ${feature}`,
|
|
177
|
-
description: mrDescription,
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
logger.info("");
|
|
181
|
-
logger.info("✅ Merge/Pull Request criado com sucesso!");
|
|
182
|
-
logger.info("");
|
|
183
|
-
logger.info(` #${mr.iid}: ${branchName} → ${targetBranch}`);
|
|
184
|
-
logger.info(` URL: ${mr.url}`);
|
|
185
|
-
logger.info("");
|
|
186
|
-
|
|
187
|
-
redisDel(`docs:${indexPath}:${targetBranch}`);
|
|
188
|
-
redisDel(`docs:${indexPath}:main`);
|
|
189
|
-
logger.info("🔄 Cache Redis invalidado");
|
|
190
|
-
logger.info("");
|
|
191
|
-
logger.info("✅ Publicação concluída!");
|
|
192
|
-
|
|
193
|
-
return { sha: commitResult.sha, mr };
|
|
121
|
+
return result;
|
|
194
122
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Gera o `{SQUAD}/index.md` do Central docs a partir do frontmatter
|
|
3
|
+
* dos documentos. O índice é sempre regenerado pela listagem (nunca editado), para
|
|
4
|
+
* duas publicações seguidas não apagarem a entrada uma da outra.
|
|
5
|
+
* @module docs/storage/build-index
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join, relative, sep } from "node:path";
|
|
10
|
+
import { parseFrontmatter } from "../../utils/frontmatter.js";
|
|
11
|
+
|
|
12
|
+
/** @constant {ReadonlyArray<string>} Áreas do Central docs, na ordem do índice */
|
|
13
|
+
export const AREAS = Object.freeze(["product", "engineering"]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Lista arquivos (recursivo) de uma pasta, em ordem alfabética.
|
|
17
|
+
* @param {string} dir
|
|
18
|
+
* @returns {string[]} caminhos absolutos
|
|
19
|
+
*/
|
|
20
|
+
export function walkFiles(dir) {
|
|
21
|
+
if (!existsSync(dir)) return [];
|
|
22
|
+
return readdirSync(dir)
|
|
23
|
+
.sort()
|
|
24
|
+
.flatMap((name) => {
|
|
25
|
+
const full = join(dir, name);
|
|
26
|
+
return statSync(full).isDirectory() ? walkFiles(full) : [full];
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Monta o conteúdo do índice de um squad.
|
|
32
|
+
* Formato: `# SQUAD` › `## área` › `### produto` › `- [arquivo](caminho) | vX | status | nome`.
|
|
33
|
+
* @param {string} root - Pasta do Central docs
|
|
34
|
+
* @param {string} squad
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function renderIndex(root, squad) {
|
|
38
|
+
const out = [`# ${squad}`, ""];
|
|
39
|
+
|
|
40
|
+
for (const area of AREAS) {
|
|
41
|
+
const areaDir = join(root, squad, area);
|
|
42
|
+
const files = walkFiles(areaDir).filter((f) => !f.endsWith(`${sep}index.md`));
|
|
43
|
+
out.push(`## ${area}`, "");
|
|
44
|
+
if (files.length === 0) {
|
|
45
|
+
out.push("_Nenhum documento._", "");
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const byProduct = new Map();
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
const [product = "", ...rest] = relative(areaDir, file).split(sep);
|
|
52
|
+
if (rest.length === 0) continue; // arquivo solto na área, sem pasta de produto
|
|
53
|
+
if (!byProduct.has(product)) byProduct.set(product, []);
|
|
54
|
+
byProduct.get(product).push(file);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const [product, productFiles] of byProduct) {
|
|
58
|
+
out.push(`### ${product}`);
|
|
59
|
+
for (const file of productFiles) {
|
|
60
|
+
const rel = relative(join(root, squad), file).split(sep).join("/");
|
|
61
|
+
const name = rel.split("/").pop();
|
|
62
|
+
const meta = file.endsWith(".md") ? parseFrontmatter(readFileSync(file, "utf-8")) : {};
|
|
63
|
+
let line = `- [${name}](${rel})`;
|
|
64
|
+
if (meta.version) line += ` | v${meta.version}`;
|
|
65
|
+
if (meta.status) line += ` | ${meta.status}`;
|
|
66
|
+
if (meta.name) line += ` | ${meta.name}`;
|
|
67
|
+
out.push(line);
|
|
68
|
+
}
|
|
69
|
+
out.push("");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return out.join("\n").replace(/\n+$/, "\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Regenera e grava `{root}/{squad}/index.md`.
|
|
78
|
+
* @param {string} root - Pasta do Central docs
|
|
79
|
+
* @param {string} squad
|
|
80
|
+
* @returns {{ path: string, content: string }}
|
|
81
|
+
*/
|
|
82
|
+
export function buildIndex(root, squad) {
|
|
83
|
+
const content = renderIndex(root, squad);
|
|
84
|
+
const path = join(root, squad, "index.md");
|
|
85
|
+
writeFileSync(path, content);
|
|
86
|
+
return { path, content };
|
|
87
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Adaptador `filesystem` do Central docs: uma pasta do disco
|
|
3
|
+
* (dentro do workspace ou em qualquer caminho). Se a pasta tiver `.git` (clone
|
|
4
|
+
* de um repositório de documentação), liga a camada git: `pull` antes de ler e
|
|
5
|
+
* branch + commit + push + PR ao publicar.
|
|
6
|
+
*
|
|
7
|
+
* Todo comando externo usa `execFileSync` com argumentos (sem shell), e todo
|
|
8
|
+
* caminho recebido é resolvido dentro da pasta (sem `..`).
|
|
9
|
+
* @module docs/storage/filesystem
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
import { createMergeRequest as defaultCreateMergeRequest } from "../../vcs/api.js";
|
|
16
|
+
import { logger } from "../../utils/logger.js";
|
|
17
|
+
import { buildIndex, walkFiles } from "./build-index.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Executa `git` na pasta informada, sem shell.
|
|
21
|
+
* @param {string[]} args
|
|
22
|
+
* @param {string} cwd
|
|
23
|
+
* @returns {string} stdout sem espaços nas pontas
|
|
24
|
+
*/
|
|
25
|
+
export function runGit(args, cwd) {
|
|
26
|
+
return execFileSync("git", args, {
|
|
27
|
+
cwd,
|
|
28
|
+
encoding: "utf-8",
|
|
29
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
30
|
+
}).trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve um caminho relativo dentro de `root`, rejeitando o que sair dela.
|
|
35
|
+
* @param {string} root
|
|
36
|
+
* @param {string} rel
|
|
37
|
+
* @returns {string} caminho absoluto
|
|
38
|
+
* @throws {Error} caminho absoluto, com `..` que escape da pasta, ou vazio
|
|
39
|
+
*/
|
|
40
|
+
export function safePath(root, rel) {
|
|
41
|
+
if (!rel || isAbsolute(rel)) {
|
|
42
|
+
throw new Error(`Caminho inválido para o Central docs: "${rel}"`);
|
|
43
|
+
}
|
|
44
|
+
const full = resolve(root, rel);
|
|
45
|
+
const fromRoot = relative(root, full);
|
|
46
|
+
if (fromRoot === "" || fromRoot.startsWith("..") || isAbsolute(fromRoot)) {
|
|
47
|
+
throw new Error(`Caminho fora da pasta do Central docs: "${rel}"`);
|
|
48
|
+
}
|
|
49
|
+
return full;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {Object} cfg
|
|
54
|
+
* @param {string} cfg.location - Pasta do Central docs (absoluta)
|
|
55
|
+
* @param {string} [cfg.ref] - Branch lida no `pull` (CENTRAL_DOCS_REF)
|
|
56
|
+
* @param {string} [cfg.targetBranch] - Branch base do PR (CENTRAL_DOCS_TARGET_BRANCH)
|
|
57
|
+
* @param {string} [cfg.reviewers] - Revisores (vão na descrição do PR)
|
|
58
|
+
* @param {Object} [cfg.deps] - `runGit` e `createMergeRequest` injetáveis (testes)
|
|
59
|
+
*/
|
|
60
|
+
export function createFilesystemStorage({
|
|
61
|
+
location,
|
|
62
|
+
ref = "main",
|
|
63
|
+
targetBranch = "main",
|
|
64
|
+
reviewers = "",
|
|
65
|
+
deps = {},
|
|
66
|
+
}) {
|
|
67
|
+
const git = deps.runGit ?? runGit;
|
|
68
|
+
const createMergeRequest = deps.createMergeRequest ?? defaultCreateMergeRequest;
|
|
69
|
+
const isGit = existsSync(join(location, ".git"));
|
|
70
|
+
let pulled = false;
|
|
71
|
+
|
|
72
|
+
/** `git pull --ff-only` uma vez por instância; falha vira aviso e a leitura segue local. */
|
|
73
|
+
function pullOnce() {
|
|
74
|
+
if (!isGit || pulled) return;
|
|
75
|
+
pulled = true;
|
|
76
|
+
try {
|
|
77
|
+
git(["pull", "--ff-only", "origin", ref], location);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
logger.warn(`⚠️ Não foi possível atualizar o Central docs (git pull): ${error.message.split("\n")[0]}`);
|
|
80
|
+
logger.warn(" Usando a cópia local.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ensureLocation() {
|
|
85
|
+
if (!existsSync(location)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Pasta do Central docs não encontrada: ${location}\nCrie a pasta ou ajuste CENTRAL_DOCS_LOCATION no ENV.md (/jarvis-init).`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
kind: "filesystem",
|
|
94
|
+
location,
|
|
95
|
+
isGit,
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Lê um documento.
|
|
99
|
+
* @param {string} path - Caminho dentro do Central docs (ex: `CORE/index.md`)
|
|
100
|
+
* @returns {Promise<string>}
|
|
101
|
+
* @throws {Error} com `code = 404` se o arquivo não existir
|
|
102
|
+
*/
|
|
103
|
+
async fetch(path) {
|
|
104
|
+
ensureLocation();
|
|
105
|
+
pullOnce();
|
|
106
|
+
const full = safePath(location, path);
|
|
107
|
+
if (!existsSync(full)) {
|
|
108
|
+
const err = new Error(`Arquivo não encontrado no Central docs: ${path}`);
|
|
109
|
+
err.code = 404;
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
return readFileSync(full, "utf-8");
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Lista os `.md` sob um prefixo.
|
|
117
|
+
* @param {string} prefix
|
|
118
|
+
* @returns {Promise<string[]>} caminhos relativos à pasta, com `/`
|
|
119
|
+
*/
|
|
120
|
+
async list(prefix) {
|
|
121
|
+
ensureLocation();
|
|
122
|
+
pullOnce();
|
|
123
|
+
return walkFiles(safePath(location, prefix))
|
|
124
|
+
.filter((f) => f.endsWith(".md"))
|
|
125
|
+
.map((f) => relative(location, f).split(sep).join("/"));
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Regenera `{squad}/index.md`. Com `.git` e `scripts/build-index.sh` no repositório,
|
|
130
|
+
* usa o script dele (o CI do repositório confere o índice com ele).
|
|
131
|
+
* @param {string} squad
|
|
132
|
+
* @returns {string} caminho relativo do índice
|
|
133
|
+
*/
|
|
134
|
+
buildIndex(squad) {
|
|
135
|
+
const script = join(location, "scripts", "build-index.sh");
|
|
136
|
+
if (isGit && existsSync(script)) {
|
|
137
|
+
execFileSync("bash", [script], { cwd: location, stdio: ["ignore", "pipe", "pipe"] });
|
|
138
|
+
} else {
|
|
139
|
+
buildIndex(location, squad);
|
|
140
|
+
}
|
|
141
|
+
return `${squad}/index.md`;
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Grava um documento e regenera o índice do squad.
|
|
146
|
+
* Sem `.git`: copia e pronto. Com `.git`: branch → cópia → índice → commit → push → PR.
|
|
147
|
+
* @param {Object} p
|
|
148
|
+
* @param {string} p.localFile - Arquivo de origem
|
|
149
|
+
* @param {string} p.destPath - Destino dentro do Central docs (`{SQUAD}/{área}/{produto}/{arquivo}`)
|
|
150
|
+
* @param {string} p.squad
|
|
151
|
+
* @param {string} p.tipo
|
|
152
|
+
* @returns {Promise<{ destPath: string, indexPath: string, sha?: string, branch?: string, mr?: Object }>}
|
|
153
|
+
*/
|
|
154
|
+
async publish({ localFile, destPath, squad, tipo }) {
|
|
155
|
+
ensureLocation();
|
|
156
|
+
const dest = safePath(location, destPath);
|
|
157
|
+
|
|
158
|
+
if (!isGit) {
|
|
159
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
160
|
+
copyFileSync(localFile, dest);
|
|
161
|
+
const indexPath = this.buildIndex(squad);
|
|
162
|
+
return { destPath, indexPath };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const branch = `docs/${destPath.replace(extname(destPath), "")}`;
|
|
166
|
+
git(["checkout", targetBranch], location);
|
|
167
|
+
try {
|
|
168
|
+
git(["pull", "--ff-only", "origin", targetBranch], location);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
logger.warn(`⚠️ git pull falhou (${error.message.split("\n")[0]}); seguindo com a cópia local.`);
|
|
171
|
+
}
|
|
172
|
+
git(["checkout", "-b", branch], location);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
176
|
+
copyFileSync(localFile, dest);
|
|
177
|
+
const indexPath = this.buildIndex(squad);
|
|
178
|
+
git(["add", "--", destPath, indexPath], location);
|
|
179
|
+
git(["commit", "-m", `docs(${squad}): add ${tipo} ${basename(destPath)}`], location);
|
|
180
|
+
const sha = git(["rev-parse", "HEAD"], location);
|
|
181
|
+
|
|
182
|
+
git(["push", "-u", "origin", branch], location);
|
|
183
|
+
const remoteUrl = git(["remote", "get-url", "origin"], location);
|
|
184
|
+
let description = `## Documento\n\n- **Tipo**: ${tipo}\n- **Arquivo**: \`${destPath}\``;
|
|
185
|
+
if (reviewers) description += `\n- **Revisores**: ${reviewers}`;
|
|
186
|
+
const mr = await createMergeRequest(remoteUrl, {
|
|
187
|
+
source: branch,
|
|
188
|
+
target: targetBranch,
|
|
189
|
+
title: `docs(${squad}): ${tipo} ${basename(destPath)}`,
|
|
190
|
+
description,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return { destPath, indexPath, sha, branch, mr };
|
|
194
|
+
} finally {
|
|
195
|
+
try {
|
|
196
|
+
git(["checkout", targetBranch], location);
|
|
197
|
+
} catch {
|
|
198
|
+
// a branch de trabalho fica; o erro original (se houver) é o que importa
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Fábrica dos adaptadores de armazenamento do Central docs.
|
|
3
|
+
* O valor de `CENTRAL_DOCS_STORAGE` escolhe o adaptador; `CENTRAL_DOCS_LOCATION`
|
|
4
|
+
* diz onde ele guarda. Hoje só existe o `filesystem` (com camada git opcional);
|
|
5
|
+
* S3 e Google Drive são as issues #144 e #145.
|
|
6
|
+
* @module docs/storage
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { isAbsolute, resolve } from "node:path";
|
|
10
|
+
import { createFilesystemStorage } from "./filesystem.js";
|
|
11
|
+
|
|
12
|
+
/** @constant {ReadonlyArray<string>} Adaptadores disponíveis */
|
|
13
|
+
export const SUPPORTED_STORAGES = Object.freeze(["filesystem"]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Erro de configuração do Central docs (variável ausente, valor não suportado).
|
|
17
|
+
*/
|
|
18
|
+
export class StorageConfigError extends Error {}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve `CENTRAL_DOCS_LOCATION` a partir da pasta do workspace (a que contém `$IDE/`).
|
|
22
|
+
* @param {string} location
|
|
23
|
+
* @param {string} root - Pasta do workspace
|
|
24
|
+
* @returns {string} caminho absoluto
|
|
25
|
+
*/
|
|
26
|
+
export function resolveLocation(location, root) {
|
|
27
|
+
return isAbsolute(location) ? location : resolve(root, location);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Devolve o adaptador configurado no ENV.md, ou `null` se o Central docs estiver desligado.
|
|
32
|
+
*
|
|
33
|
+
* @param {Record<string, string>} env - Variáveis do ENV.md (`loadEnv`)
|
|
34
|
+
* @param {Object} opts
|
|
35
|
+
* @param {string} opts.root - Pasta do workspace (`resolveEnvPath(...).root`)
|
|
36
|
+
* @param {Object} [opts.deps] - Dependências injetáveis (testes): `runGit`, `createMergeRequest`
|
|
37
|
+
* @returns {ReturnType<typeof createFilesystemStorage> | null}
|
|
38
|
+
* @throws {StorageConfigError} valor de STORAGE não suportado, LOCATION ausente ou variável antiga
|
|
39
|
+
*/
|
|
40
|
+
export function getStorage(env, { root, deps } = {}) {
|
|
41
|
+
const storage = (env.CENTRAL_DOCS_STORAGE || "").trim().toLowerCase();
|
|
42
|
+
|
|
43
|
+
if (!storage) {
|
|
44
|
+
if (env.CENTRAL_DOCS_REPO) {
|
|
45
|
+
throw new StorageConfigError(
|
|
46
|
+
"CENTRAL_DOCS_REPO não é mais usado. Rode o /jarvis-init e escolha Upgrade (C) para migrar " +
|
|
47
|
+
"para CENTRAL_DOCS_STORAGE + CENTRAL_DOCS_LOCATION."
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!SUPPORTED_STORAGES.includes(storage)) {
|
|
54
|
+
throw new StorageConfigError(
|
|
55
|
+
`Armazenamento "${storage}" ainda não suportado. Use: ${SUPPORTED_STORAGES.join(", ")}.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!env.CENTRAL_DOCS_LOCATION) {
|
|
60
|
+
throw new StorageConfigError(
|
|
61
|
+
"CENTRAL_DOCS_LOCATION não definido no ENV.md (pasta do Central docs)."
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (!root) {
|
|
65
|
+
throw new StorageConfigError("getStorage: pasta do workspace (root) não informada.");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return createFilesystemStorage({
|
|
69
|
+
location: resolveLocation(env.CENTRAL_DOCS_LOCATION, root),
|
|
70
|
+
ref: env.CENTRAL_DOCS_REF || "main",
|
|
71
|
+
targetBranch: env.CENTRAL_DOCS_TARGET_BRANCH || "main",
|
|
72
|
+
reviewers: env.CENTRAL_DOCS_REVIEWERS || "",
|
|
73
|
+
deps,
|
|
74
|
+
});
|
|
75
|
+
}
|