@aksp/opencrew 1.6.2 → 1.7.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 (71) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +70 -13
  3. package/package.json +2 -2
  4. package/src/cli.js +15 -38
  5. package/src/commands/init.js +41 -44
  6. package/src/commands/update.js +78 -75
  7. package/src/lib/blocos.js +148 -0
  8. package/src/lib/deteccao.js +69 -0
  9. package/src/lib/fsx.js +1 -55
  10. package/src/lib/ides.js +4 -0
  11. package/src/lib/legado.js +142 -0
  12. package/src/lib/manifest.js +67 -26
  13. package/src/lib/mcp.js +131 -0
  14. package/src/lib/migrations.js +77 -74
  15. package/src/lib/node-version.js +43 -0
  16. package/src/lib/prompts.js +25 -2
  17. package/src/lib/resumo.js +125 -0
  18. package/templates/.mcp.json +1 -1
  19. package/templates/AGENTS.md +20 -6
  20. package/templates/_opencrew/.opencrew-version +1 -1
  21. package/templates/_opencrew/core/best-practices/social-networks-publishing.md +14 -14
  22. package/templates/_opencrew/core/escritorio/animacao.js +64 -0
  23. package/templates/_opencrew/core/escritorio/app.js +137 -0
  24. package/templates/_opencrew/core/escritorio/cena.js +132 -0
  25. package/templates/_opencrew/core/escritorio/demo.js +79 -0
  26. package/templates/_opencrew/core/escritorio/escala.js +27 -0
  27. package/templates/_opencrew/core/escritorio/index.html +166 -0
  28. package/templates/_opencrew/core/escritorio/modelo-agentes.js +93 -0
  29. package/templates/_opencrew/core/escritorio/modelo-estado.js +71 -0
  30. package/templates/_opencrew/core/escritorio/modelo-mesas.js +81 -0
  31. package/templates/_opencrew/core/escritorio/modelo-pagina.js +95 -0
  32. package/templates/_opencrew/core/escritorio/modelo-textos.js +65 -0
  33. package/templates/_opencrew/core/escritorio/modelo-visao.js +91 -0
  34. package/templates/_opencrew/core/escritorio/modelo.js +29 -0
  35. package/templates/_opencrew/core/escritorio/painel.js +120 -0
  36. package/templates/_opencrew/core/escritorio/quadro.js +106 -0
  37. package/templates/_opencrew/core/escritorio/rota.js +62 -0
  38. package/templates/_opencrew/core/escritorio/rotulos.js +78 -0
  39. package/templates/_opencrew/core/escritorio/sprites-mesa.js +122 -0
  40. package/templates/_opencrew/core/escritorio/sprites-sala.js +92 -0
  41. package/templates/_opencrew/core/escritorio/sprites.js +187 -0
  42. package/templates/_opencrew/core/prompts/build.prompt.md +3 -3
  43. package/templates/_opencrew/core/prompts/export.prompt.md +1 -1
  44. package/templates/_opencrew/core/prompts/repair.prompt.md +7 -12
  45. package/templates/_opencrew/core/prompts/sherlock-shared.md +5 -5
  46. package/templates/_opencrew/core/runner.pipeline.md +76 -139
  47. package/templates/_opencrew/core/scripts/comum.mjs +49 -4
  48. package/templates/_opencrew/core/scripts/conferir-fontes/busca.mjs +42 -3
  49. package/templates/_opencrew/core/scripts/conferir-fontes/relatorio.mjs +18 -6
  50. package/templates/_opencrew/core/scripts/conferir-fontes.mjs +97 -39
  51. package/templates/_opencrew/core/scripts/escritorio/leitura.mjs +31 -0
  52. package/templates/_opencrew/core/scripts/escritorio/porta.mjs +98 -0
  53. package/templates/_opencrew/core/scripts/escritorio/projeto.mjs +29 -0
  54. package/templates/_opencrew/core/scripts/escritorio/servidor.mjs +78 -0
  55. package/templates/_opencrew/core/scripts/escritorio.mjs +117 -0
  56. package/templates/_opencrew/core/scripts/estado/argumentos.mjs +61 -0
  57. package/templates/_opencrew/core/scripts/estado/arquivo.mjs +53 -0
  58. package/templates/_opencrew/core/scripts/estado/decisao.mjs +56 -0
  59. package/templates/_opencrew/core/scripts/estado/elenco.mjs +58 -0
  60. package/templates/_opencrew/core/scripts/estado/nucleo.mjs +113 -0
  61. package/templates/_opencrew/core/scripts/estado/preferencia.mjs +24 -0
  62. package/templates/_opencrew/core/scripts/estado.mjs +96 -0
  63. package/templates/_opencrew/core/scripts/verificar.mjs +7 -4
  64. package/templates/_opencrew/core/skills.engine.md +7 -3
  65. package/templates/gitignore +1 -0
  66. package/templates/skills/blotato/SKILL.md +39 -10
  67. package/templates/skills/image-ai-generator/SKILL.md +18 -5
  68. package/templates/skills/image-ai-generator/scripts/generate.py +52 -10
  69. package/templates/skills/instagram-publisher/SKILL.md +4 -0
  70. package/templates/skills/opencrew-skill-creator/references/skill-format.md +1 -0
  71. package/templates/skills/resend/SKILL.md +52 -13
@@ -14,76 +14,117 @@ const normalize = (buf) => {
14
14
  const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
15
15
  return b.includes(0) ? b : Buffer.from(b.toString('utf8').replace(/\r\n/g, '\n'));
16
16
  };
17
- const sha = (buf) => createHash('sha256').update(normalize(buf)).digest('hex');
17
+ /** sha256 of a file or text as the manifest records it (text with LF line endings). */
18
+ export const hashOf = (buf) => createHash('sha256').update(normalize(buf)).digest('hex');
18
19
  const rel = (target, abs) => path.relative(target, abs).split(path.sep).join('/');
19
20
 
20
- /** @returns {Promise<{files: Record<string,string>} | null>} null = no (or unreadable) manifest */
21
+ /** A JSON object: not null, not an array. */
22
+ export const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
23
+
24
+ /**
25
+ * The manifest, when it can be used: valid JSON whose `files` is an object with at least one
26
+ * entry. @returns {Promise<{files: Record<string,string>} | null>} null = none, or unreadable
27
+ */
21
28
  export async function readManifest(target) {
22
29
  try {
23
30
  const data = JSON.parse(await fs.readFile(path.join(target, MANIFEST), 'utf8'));
24
- return data && typeof data.files === 'object' ? data : null;
31
+ return isRecord(data?.files) && Object.keys(data.files).length ? data : null;
25
32
  } catch {
26
33
  return null;
27
34
  }
28
35
  }
29
36
 
37
+ /**
38
+ * R2 rule 12: the manifest file exists but `readManifest` cannot use it (a merge conflict, a
39
+ * file cut short). The run goes on as in a project with no record — whatever differs from the
40
+ * package is copied first — and says so with UNREADABLE. A missing manifest is not unreadable.
41
+ */
42
+ export async function manifestUnreadable(target) {
43
+ return (await exists(path.join(target, MANIFEST))) && !(await readManifest(target));
44
+ }
45
+
46
+ /** Spec R2 §6: `update` before it rewrites, `summary` in its list of copies, `repair` in the repair. */
47
+ export const UNREADABLE = {
48
+ update: 'O registro de arquivos (`_opencrew/manifest.json`) está ilegível. Vou tratar esta atualização como a de um projeto sem registro: todo arquivo diferente do pacote novo é copiado para `.opencrew-backup/` antes de ser substituído. O registro é refeito no fim.',
49
+ summary: 'O registro estava ilegível: guardamos tudo o que diferia do pacote novo. Daqui em diante, só o que você editar.',
50
+ repair: 'O registro de arquivos (`_opencrew/manifest.json`) está ilegível; o reparo não o refaz. Rode `npx @aksp/opencrew@latest update` para refazer.',
51
+ };
52
+
30
53
  export async function writeManifest(target, version, files) {
31
54
  const sorted = Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b)));
32
55
  await fs.writeFile(path.join(target, MANIFEST), JSON.stringify({ version, files: sorted }, null, 2) + '\n');
33
56
  }
34
57
 
35
58
  /**
36
- * Delivery context shared by one init/update run.
59
+ * Delivery context shared by one init/update/repair run. `files` = what goes into the next
60
+ * manifest; `copied` = files copied to `backupDir`; `blocks` = one result per marked block
61
+ * written by `deliverBlock` (src/lib/blocos.js); `legacy` = one entry per bridge file where
62
+ * the old text of ≤ 1.2.2 was removed or found edited (src/lib/legado.js).
37
63
  * @param {string} target project root
38
- * @param {{files:Record<string,string>}|null} manifest previous manifest (null = none)
64
+ * @param {{files:Record<string,string>}|null} manifest previous manifest (null = none, or unreadable)
39
65
  */
40
66
  export function newDelivery(target, manifest) {
41
67
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
42
- return { target, manifest, backupDir: path.join(target, '.opencrew-backup', stamp), files: {}, copied: [], written: 0 };
68
+ const backupDir = path.join(target, '.opencrew-backup', stamp);
69
+ return { target, manifest, backupDir, files: {}, copied: [], blocks: [], legacy: [], written: 0 };
70
+ }
71
+
72
+ /**
73
+ * Copy the file `key` (path relative to the project root, with `/`) to the backup dir before it
74
+ * is changed. Once per run: a second change to the same file must not replace the copy of how
75
+ * the file was. @returns {Promise<boolean>} true when this call made the copy
76
+ */
77
+ export async function backupFile(ctx, key) {
78
+ if (ctx.copied.includes(key)) return false;
79
+ const copy = path.join(ctx.backupDir, key);
80
+ await fs.mkdir(path.dirname(copy), { recursive: true });
81
+ await fs.copyFile(path.join(ctx.target, key), copy);
82
+ ctx.copied.push(key);
83
+ return true;
43
84
  }
44
85
 
45
86
  /**
46
87
  * Put `content` at `dest`. Missing → write. Equal → nothing. Different → only if `overwrite`,
47
88
  * copying the current file to the backup dir first when the user edited it.
89
+ * @returns {Promise<{file:string, action:'created'|'updated'|'kept', copied:boolean}>}
48
90
  */
49
91
  export async function deliverFile(ctx, dest, content, { overwrite }) {
50
- const key = rel(ctx.target, dest);
51
- const fresh = sha(content);
92
+ const file = rel(ctx.target, dest);
93
+ const fresh = hashOf(content);
52
94
  if (!(await exists(dest))) {
53
95
  await fs.mkdir(path.dirname(dest), { recursive: true });
54
96
  await fs.writeFile(dest, content);
55
- ctx.files[key] = fresh;
97
+ ctx.files[file] = fresh;
56
98
  ctx.written += 1;
57
- return;
58
- }
59
- const current = sha(await fs.readFile(dest));
60
- if (current === fresh) {
61
- ctx.files[key] = fresh;
62
- return;
63
- }
64
- if (!overwrite) return; // the user's version stays (and is not tracked)
65
- const edited = ctx.manifest ? ctx.manifest.files[key] !== current : true;
66
- if (edited) {
67
- const copy = path.join(ctx.backupDir, key);
68
- await fs.mkdir(path.dirname(copy), { recursive: true });
69
- await fs.copyFile(dest, copy);
70
- ctx.copied.push(key);
99
+ return { file, action: 'created', copied: false };
71
100
  }
101
+ const current = hashOf(await fs.readFile(dest));
102
+ if (current === fresh) ctx.files[file] = fresh;
103
+ // Different and no `overwrite`: the user's version stays (and is not tracked).
104
+ if (current === fresh || !overwrite) return { file, action: 'kept', copied: false };
105
+ const edited = ctx.manifest ? ctx.manifest.files[file] !== current : true;
106
+ const copied = edited && (await backupFile(ctx, file));
72
107
  await fs.writeFile(dest, content);
73
- ctx.files[key] = fresh;
108
+ ctx.files[file] = fresh;
74
109
  ctx.written += 1;
110
+ return { file, action: 'updated', copied };
75
111
  }
76
112
 
77
- /** deliverFile for every file under `src`, mirrored into `dest`. */
113
+ /**
114
+ * deliverFile for every file under `src`, mirrored into `dest`.
115
+ * @returns {Promise<Array<{file:string, action:string, copied:boolean}>>} one result per file
116
+ */
78
117
  export async function deliverTree(ctx, src, dest, { overwrite, skip = () => false }) {
118
+ const results = [];
79
119
  async function walk(dir) {
80
120
  for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
81
121
  const from = path.join(dir, entry.name);
82
122
  const relPath = path.relative(src, from).split(path.sep).join('/');
83
123
  if (skip(relPath)) continue;
84
124
  if (entry.isDirectory()) await walk(from);
85
- else await deliverFile(ctx, path.join(dest, relPath), await fs.readFile(from), { overwrite });
125
+ else results.push(await deliverFile(ctx, path.join(dest, relPath), await fs.readFile(from), { overwrite }));
86
126
  }
87
127
  }
88
128
  await walk(src);
129
+ return results;
89
130
  }
package/src/lib/mcp.js ADDED
@@ -0,0 +1,131 @@
1
+ // The project's .mcp.json (R2 rules 8 to 11). The Playwright server OpenCrew ships is delivered
2
+ // ONCE, and the manifest records it (`.mcp.json` → sha256 of the text, like any delivered file):
3
+ // `init` records when it creates the file; `update` without the record creates the file or adds
4
+ // the server — whichever is missing — and records. With the record, `update` never creates the
5
+ // file nor puts the server back: removing it is the user's choice. Every rewrite copies the
6
+ // whole file to .opencrew-backup/ first and keeps its indentation and line ending. The pinned
7
+ // version is never changed, and a file out of the expected format is left as it is.
8
+ import { promises as fs } from 'node:fs';
9
+ import path from 'node:path';
10
+ import { exists } from './fsx.js';
11
+ import { backupFile, hashOf, isRecord } from './manifest.js';
12
+
13
+ /** Path of the file, relative to the project root: also its key in the manifest. */
14
+ export const MCP_FILE = '.mcp.json';
15
+
16
+ // The entry that is OpenCrew's own is the one that uses its Playwright config.
17
+ const CONFIG = '_opencrew/config/playwright.config.json';
18
+ const OUTPUT_DIR = ['--output-dir', '_opencrew/logs/playwright'];
19
+ const SKILLS = 'as skills image-creator e image-fetcher';
20
+
21
+ const hasOutputDir = (args) => args.some((a) => a === '--output-dir' || String(a).startsWith('--output-dir='));
22
+ const lacksOutputDir = (args) => Array.isArray(args) && args.some((a) => String(a).includes(CONFIG)) && !hasOutputDir(args);
23
+
24
+ /**
25
+ * `init`: write the template when the project has no .mcp.json, and record the delivery. An
26
+ * existing file is the user's: it stays as it is and nothing is recorded.
27
+ * @returns {Promise<boolean>} true when the file was created
28
+ */
29
+ export async function createMcp(ctx, template) {
30
+ const dest = path.join(ctx.target, MCP_FILE);
31
+ if (await exists(dest)) return false;
32
+ await fs.writeFile(dest, template);
33
+ ctx.files[MCP_FILE] = hashOf(template);
34
+ return true;
35
+ }
36
+
37
+ /** Indentation of the first indented line of `text`; 2 spaces when no line is indented. */
38
+ function indentOf(text) {
39
+ for (let nl = text.indexOf('\n'); nl !== -1; nl = text.indexOf('\n', nl + 1)) {
40
+ let end = nl + 1;
41
+ while (text[end] === ' ' || text[end] === '\t') end += 1;
42
+ if (end > nl + 1 && end < text.length && text[end] !== '\r' && text[end] !== '\n') return text.slice(nl + 1, end);
43
+ }
44
+ return ' ';
45
+ }
46
+
47
+ const BOM = String.fromCharCode(0xfeff); // some editors on Windows save JSON with it
48
+
49
+ /** `data` as JSON in the indentation, line ending and final line break of `text`. */
50
+ function formatLike(text, data) {
51
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
52
+ const json = (text.startsWith(BOM) ? BOM : '') + JSON.stringify(data, null, indentOf(text)).replace(/\n/g, eol);
53
+ return text.endsWith('\n') ? json + eol : json;
54
+ }
55
+
56
+ /**
57
+ * What `update` does to a .mcp.json whose content is `text`. `delivered` = the manifest has the
58
+ * record. `data` (what to write) comes only with the two actions that change the file.
59
+ */
60
+ function planMcp(text, template, delivered) {
61
+ let data;
62
+ try { data = JSON.parse(text.startsWith(BOM) ? text.slice(1) : text); } catch { return { action: 'not-json' }; }
63
+ // An object with no `mcpServers` counts as one with no server; any other shape is left alone.
64
+ const servers = isRecord(data) && data.mcpServers === undefined ? {} : data?.mcpServers;
65
+ if (!isRecord(servers)) return { action: 'bad-format' };
66
+ if (!Object.hasOwn(servers, 'playwright')) {
67
+ if (delivered) return { action: 'no-server' };
68
+ data.mcpServers = { ...servers, playwright: JSON.parse(template).mcpServers.playwright };
69
+ return { action: 'added', data };
70
+ }
71
+ const args = servers.playwright?.args;
72
+ if (!lacksOutputDir(args)) return { action: 'kept' };
73
+ args.push(...OUTPUT_DIR);
74
+ return { action: 'output-dir', data };
75
+ }
76
+
77
+ /**
78
+ * `update`: deliver the Playwright server once and send the output of the OpenCrew entry to
79
+ * _opencrew/logs/playwright/. The record always goes on to the next manifest (`ctx.files`).
80
+ * - `created` / `added`: no record — the missing file was written, or the missing server added;
81
+ * - `missing` / `no-server`: recorded before — not created, not put back;
82
+ * - `output-dir`: the OpenCrew entry had neither `--output-dir` nor `--output-dir=…`;
83
+ * - `not-json` / `bad-format`: the file is left alone, and no record is made (nothing was
84
+ * delivered: once the file is fixed, the server can still arrive);
85
+ * - `kept`: nothing to do — without a record, this is what gets recorded.
86
+ * @param {string} template text of templates/.mcp.json
87
+ * @returns {Promise<{action:string, copied:boolean}>} `copied` = the file was copied before the rewrite
88
+ */
89
+ export async function updateMcp(ctx, template) {
90
+ const dest = path.join(ctx.target, MCP_FILE);
91
+ const record = ctx.manifest?.files?.[MCP_FILE];
92
+ if (record) ctx.files[MCP_FILE] = record;
93
+ if (!(await exists(dest))) {
94
+ if (!record) await createMcp(ctx, template);
95
+ return { action: record ? 'missing' : 'created', copied: false };
96
+ }
97
+ const text = await fs.readFile(dest, 'utf8');
98
+ const plan = planMcp(text, template, Boolean(record));
99
+ if (!plan.data) {
100
+ if (plan.action === 'kept' && !record) ctx.files[MCP_FILE] = hashOf(text); // nothing was missing
101
+ return { action: plan.action, copied: false };
102
+ }
103
+ const copied = await backupFile(ctx, MCP_FILE);
104
+ const rewritten = formatLike(text, plan.data);
105
+ await fs.writeFile(dest, rewritten);
106
+ ctx.files[MCP_FILE] = hashOf(rewritten);
107
+ return { action: plan.action, copied };
108
+ }
109
+
110
+ // Spec R2 §6. `copy` = where the copy of the file is, for the two actions that rewrite it.
111
+ const MESSAGES = {
112
+ created: () => `\`.mcp.json\` criado com o servidor Playwright do OpenCrew (${SKILLS} precisam dele). Se você apagou esse arquivo de propósito, pode apagar de novo: o \`update\` não recria mais.`,
113
+ added: (copy) => `Servidor Playwright acrescentado ao \`.mcp.json\` ${copy}. Se você o removeu de propósito, pode remover de novo: o \`update\` não repõe mais.`,
114
+ 'output-dir': (copy) => `\`.mcp.json\`: a saída do Playwright agora vai para \`_opencrew/logs/playwright/\` ${copy}.`,
115
+ missing: () => `O \`.mcp.json\` não existe e não foi recriado. Sem o servidor Playwright, ${SKILLS} não funcionam.`,
116
+ 'no-server': () => `O \`.mcp.json\` está sem o servidor Playwright do OpenCrew e ficou como está. Sem ele, ${SKILLS} não funcionam.`,
117
+ 'bad-format': () => 'O `.mcp.json` não tem o formato esperado (um objeto com `mcpServers`) e não foi alterado. Confira o arquivo.',
118
+ 'not-json': () => '.mcp.json não é um JSON válido — não alterado. Confira o arquivo.',
119
+ };
120
+ const DONE = new Set(['created', 'added', 'output-dir']);
121
+
122
+ /**
123
+ * The line `update` prints for an action of `updateMcp`; null when there is nothing to say.
124
+ * @returns {{text:string, warn:boolean}|null} `warn` = a warning, not something that was done
125
+ */
126
+ export function mcpMessage(ctx, action) {
127
+ if (!MESSAGES[action]) return null;
128
+ const dir = path.relative(ctx.target, ctx.backupDir).split(path.sep).join('/');
129
+ const text = MESSAGES[action](`(cópia do arquivo anterior em \`${dir}/${MCP_FILE}\`)`);
130
+ return { text, warn: !DONE.has(action) };
131
+ }
@@ -1,11 +1,14 @@
1
1
  // What `update` does beyond refreshing _opencrew/core and the catalog skills, so that every
2
- // improvement reaches people who already use OpenCrew (AGENTS.md rule 14). The IDE detection
3
- // is shared with `init --repair-bridges`, whose helpers live here too.
2
+ // improvement reaches people who already use OpenCrew (AGENTS.md rule 14). The helpers of
3
+ // `init --repair-bridges` live here too, with the version guard the two commands share.
4
+ // The IDE detection itself is in ./deteccao.js.
4
5
  import { promises as fs } from 'node:fs';
5
6
  import path from 'node:path';
6
- import { exists, writeBridgeFile } from './fsx.js';
7
- import { IDES, allIdeIds } from './ides.js';
8
- import { deliverFile, writeManifest } from './manifest.js';
7
+ import { exists } from './fsx.js';
8
+ import { allIdeIds } from './ides.js';
9
+ import { writeManifest } from './manifest.js';
10
+ import { copyLine } from './blocos.js';
11
+ import { detectInstalledIdes } from './deteccao.js';
9
12
 
10
13
  /** Semver compare (no pre-release tags): >0 if a > b, <0 if a < b, 0 if equal. */
11
14
  export function compareVersions(a, b) {
@@ -18,33 +21,27 @@ export function compareVersions(a, b) {
18
21
  return 0;
19
22
  }
20
23
 
21
- const sharedPaths = (() => {
22
- const count = new Map();
23
- for (const ide of IDES) for (const f of ide.files) count.set(f.path, (count.get(f.path) ?? 0) + 1);
24
- return new Set([...count].filter(([, n]) => n > 1).map(([p]) => p));
25
- })();
26
-
27
- async function hasOpencrew(file) {
28
- return (await exists(file)) && /opencrew/i.test(await fs.readFile(file, 'utf8'));
24
+ /** Version stamped in the workspace by the last init or update that finished; null = no stamp. */
25
+ export async function installedVersion(target) {
26
+ const stamp = path.join(target, '_opencrew', '.opencrew-version');
27
+ return (await exists(stamp)) ? (await fs.readFile(stamp, 'utf8')).trim() : null;
29
28
  }
30
29
 
30
+ const RUN_UPDATE = 'Nada foi alterado. Rode `npx @aksp/opencrew@latest update`: ele já atualiza as pontes. Para a ponte de uma IDE nova, repita este comando depois.';
31
+
31
32
  /**
32
- * IDEs installed in `target`, detected by their own bridge files (a path shared by several
33
- * IDEs only counts for an IDE that has no file of its own — e.g. Codex).
33
+ * Spec R2, rule 6: `init --repair-bridges` only rewrites with the package at the version
34
+ * stamped in the workspace. An older package would bring old bridges back; a newer one would
35
+ * point them to files the project does not have yet. No stamp (interrupted install): it runs.
36
+ * @returns {Promise<string|null>} the message that stops the repair, or null to go on
34
37
  */
35
- export async function detectInstalledIdes(target) {
36
- const found = [];
37
- for (const ide of IDES) {
38
- const own = ide.files.filter((f) => !sharedPaths.has(f.path));
39
- const probes = own.length ? own : ide.files;
40
- for (const f of probes) {
41
- if (await hasOpencrew(path.join(target, f.path))) {
42
- found.push(ide);
43
- break;
44
- }
45
- }
46
- }
47
- return found;
38
+ export async function repairVersionGuard(target, version) {
39
+ const installed = await installedVersion(target);
40
+ const diff = installed ? compareVersions(installed, version) : 0;
41
+ if (!diff) return null;
42
+ return diff > 0
43
+ ? `Você tem a v${installed} instalada e este pacote é a v${version} (mais antigo). ${RUN_UPDATE}`
44
+ : `Este projeto está na v${installed} e este pacote é a v${version} (mais novo). O reparo não atualiza o projeto. ${RUN_UPDATE}`;
48
45
  }
49
46
 
50
47
  /**
@@ -79,57 +76,35 @@ export function backupSummary(ctx) {
79
76
  const dir = path.relative(ctx.target, ctx.backupDir).split(path.sep).join('/');
80
77
  return [
81
78
  `${ctx.copied.length} cópia(s) de segurança feita(s) antes de regravar:`,
82
- ...ctx.copied.map((file) => ` ${dir}/${file}`),
79
+ ...ctx.copied.map((file) => ` ${copyLine(ctx, file, `${dir}/${file}`)}`),
83
80
  ];
84
81
  }
85
82
 
86
- /** Rewrite the bridges of the installed IDEs only (frontmatter files whole, others by block). */
87
- export async function refreshBridges(ctx, ides) {
88
- const done = new Set();
89
- for (const ide of ides) {
90
- for (const f of ide.files) {
91
- if (done.has(f.path)) continue;
92
- done.add(f.path);
93
- const file = path.join(ctx.target, f.path);
94
- if (f.content.startsWith('---')) await deliverFile(ctx, file, f.content, { overwrite: true });
95
- else await writeBridgeFile(file, f.content);
96
- }
97
- }
98
- }
83
+ // The bridges themselves are written by `deliverBridges` (./blocos.js), for init, update and
84
+ // repair; the project's .mcp.json is handled in ./mcp.js.
99
85
 
100
- const OUTPUT_DIR = ['--output-dir', '_opencrew/logs/playwright'];
86
+ // ── What an old OpenSquad install left behind (R2 rule 16): only reported, never touched ──
101
87
 
102
- /**
103
- * Merge the Playwright server of the template into the project's .mcp.json without touching
104
- * other servers. @returns 'created' | 'updated' | 'unchanged' | 'invalid'
105
- */
106
- export async function mergeMcp(target, templateFile) {
107
- const file = path.join(target, '.mcp.json');
108
- const template = JSON.parse(await fs.readFile(templateFile, 'utf8'));
109
- if (!(await exists(file))) {
110
- await fs.writeFile(file, JSON.stringify(template, null, 2) + '\n');
111
- return 'created';
112
- }
113
- let current;
114
- try {
115
- current = JSON.parse(await fs.readFile(file, 'utf8'));
116
- } catch {
117
- return 'invalid';
118
- }
119
- current.mcpServers ??= {};
120
- const pw = current.mcpServers.playwright;
121
- if (!pw) current.mcpServers.playwright = template.mcpServers.playwright;
122
- else if (Array.isArray(pw.args) && !pw.args.includes('--output-dir')) pw.args.push(...OUTPUT_DIR);
123
- else return 'unchanged';
124
- await fs.writeFile(file, JSON.stringify(current, null, 2) + '\n');
125
- return 'updated';
126
- }
88
+ const OLD_DIR = '_opensquad/';
89
+ // Folders where a `.md` that cites `_opensquad/` is reported, down to two subfolders.
90
+ const LEFTOVER_ROOTS = ['.gemini/skills', '.claude/skills', '.agents/skills', '.agent/workflows', '.agent/rules'];
91
+ // Bridges of the OpenSquad reported by their exact path (checked with `opensquad` 0.1.15).
92
+ const LEFTOVER_BRIDGES = [
93
+ '.cursor/rules/opensquad.mdc',
94
+ '.cursor/commands/opensquad.md',
95
+ '.opencode/commands/opensquad.md',
96
+ '.qwen/skills/opensquad/SKILL.md',
97
+ '.trae/rules/opensquad.md',
98
+ '.github/prompts/opensquad.prompt.md',
99
+ '.agents/skills/opensquad/SKILL.md',
100
+ ];
127
101
 
128
- const LEGACY_ROOTS = ['.gemini/skills', '.claude/skills', '.agents/skills', '.agent/workflows', '.agent/rules'];
102
+ /** A folder `opensquad/` or a file `opensquad.*` in the path: a bridge of the OpenSquad. */
103
+ const namedOpensquad = (file) => file.split('/').some((part, i, parts) => (
104
+ i < parts.length - 1 ? part === 'opensquad' : part.startsWith('opensquad.')));
129
105
 
130
- /** Old bridges that point to a system no longer installed (e.g. `_opensquad/`). Never deleted. */
131
- export async function findLegacyBridges(target) {
132
- if (await exists(path.join(target, '_opensquad'))) return [];
106
+ /** The `.md` files under `root` (two subfolders deep) that cite `_opensquad/`. */
107
+ async function citingOldDir(target, root) {
133
108
  const found = [];
134
109
  async function walk(dir, depth) {
135
110
  let entries;
@@ -137,11 +112,39 @@ export async function findLegacyBridges(target) {
137
112
  for (const e of entries) {
138
113
  const p = path.join(dir, e.name);
139
114
  if (e.isDirectory() && depth < 2) await walk(p, depth + 1);
140
- else if (e.isFile() && e.name.endsWith('.md') && /_opensquad\//.test(await fs.readFile(p, 'utf8'))) {
115
+ else if (e.isFile() && e.name.endsWith('.md') && (await fs.readFile(p, 'utf8')).includes(OLD_DIR)) {
141
116
  found.push(path.relative(target, p).split(path.sep).join('/'));
142
117
  }
143
118
  }
144
119
  }
145
- for (const root of LEGACY_ROOTS) await walk(path.join(target, root), 0);
120
+ await walk(path.join(target, root), 0);
146
121
  return found;
147
122
  }
123
+
124
+ /** The file of `_opensquad/` the `playwright` server of .mcp.json points to; null when none. */
125
+ async function oldPlaywrightConfig(target) {
126
+ let args;
127
+ try {
128
+ args = JSON.parse(await fs.readFile(path.join(target, '.mcp.json'), 'utf8')).mcpServers.playwright.args;
129
+ } catch { return null; }
130
+ if (!Array.isArray(args)) return null;
131
+ const values = args.map((a) => String(a)).map((a) => (a.startsWith('--') && a.includes('=') ? a.slice(a.indexOf('=') + 1) : a));
132
+ return values.find((v) => v.replace(/\\/g, '/').replace(/^\.\//, '').startsWith(OLD_DIR)) ?? null;
133
+ }
134
+
135
+ /**
136
+ * Leftovers of an OpenSquad install that is no longer there. With `_opensquad` at the project
137
+ * root nothing is reported (the system is still installed). Nothing is deleted nor changed.
138
+ * @returns {Promise<{files: Array<{file:string, bridge:boolean}>, config: string|null}>}
139
+ * `files`, sorted by path: `bridge` = recognised by its name; false = it only cites
140
+ * `_opensquad/` and may be the user's. `config` = the path of `_opensquad/` (so, missing)
141
+ * that the `playwright` server of .mcp.json uses.
142
+ */
143
+ export async function findLeftovers(target) {
144
+ if (await exists(path.join(target, '_opensquad'))) return { files: [], config: null };
145
+ const found = new Set();
146
+ for (const root of LEFTOVER_ROOTS) for (const file of await citingOldDir(target, root)) found.add(file);
147
+ for (const file of LEFTOVER_BRIDGES) if (await exists(path.join(target, file))) found.add(file);
148
+ const files = [...found].sort().map((file) => ({ file, bridge: namedOpensquad(file) }));
149
+ return { files, config: await oldPlaywrightConfig(target) };
150
+ }
@@ -0,0 +1,43 @@
1
+ // Node floor (spec R2, rule 27): the lowest Node the package accepts, read from the
2
+ // `engines.node` of package.json, and the decision to stop when the machine is below it.
3
+ // Lives outside cli.js so the rule can be tested without touching `process.versions`.
4
+
5
+ /** a < b, comparing major.minor.patch as numbers (no prerelease tags). */
6
+ function lt(a, b) {
7
+ const pa = a.split('.').map(Number);
8
+ const pb = b.split('.').map(Number);
9
+ for (let i = 0; i < 3; i++) {
10
+ const na = pa[i] || 0;
11
+ const nb = pb[i] || 0;
12
+ if (na !== nb) return na < nb;
13
+ }
14
+ return false; // equal
15
+ }
16
+
17
+ /**
18
+ * Lowest version an engines.node range accepts: the first version of each `||` alternative,
19
+ * then the smallest of them. Handles ">=20.17.0", "^20.5", ">=18.0.0 || >=20.0.0", "20.0.0".
20
+ * @returns {string | null} null when the range names no version
21
+ */
22
+ export function minNodeVersion(range) {
23
+ let lowest = null;
24
+ for (const part of String(range ?? '').split('||')) {
25
+ const v = part.match(/\d+(?:\.\d+){0,2}/)?.[0];
26
+ if (v && (!lowest || lt(v, lowest))) lowest = v;
27
+ }
28
+ return lowest;
29
+ }
30
+
31
+ /**
32
+ * The two lines to show when `current` is below the floor of `range`; null when this Node
33
+ * may run the CLI (also when the package declares no floor).
34
+ * @returns {[string, string] | null}
35
+ */
36
+ export function nodeBelowFloor(range, current = process.versions.node) {
37
+ const floor = minNodeVersion(range);
38
+ if (!floor || !lt(current, floor)) return null;
39
+ return [
40
+ `O OpenCrew precisa do Node.js ${floor} ou mais novo. Esta máquina está com o v${current}.`,
41
+ 'Instale a versão LTS em https://nodejs.org/ e rode o comando de novo. Nada foi alterado nesta pasta.',
42
+ ];
43
+ }
@@ -3,7 +3,30 @@
3
3
  import { IDES, allIdeIds } from './ides.js';
4
4
  import { warn } from './ui.js';
5
5
 
6
- export async function pickIdes({ preselected } = {}) {
6
+ const loadCheckbox = () => import('@inquirer/checkbox');
7
+
8
+ /**
9
+ * Load the list's library. Only the import is guarded (spec R2, rule 28): on a Node where it
10
+ * does not load, the user gets the way out; Ctrl+C on the open list still passes through.
11
+ */
12
+ async function openIdeList(load) {
13
+ try {
14
+ return await load();
15
+ } catch (e) {
16
+ throw new Error(
17
+ `Não consegui abrir a lista de IDEs neste Node (v${process.versions.node}).\n`
18
+ + 'Atualize o Node em https://nodejs.org/ ou escolha as IDEs no próprio comando: '
19
+ + 'npx @aksp/opencrew init --ide=claude-code (ou --all para todas). Nada foi alterado nesta pasta.',
20
+ { cause: e },
21
+ );
22
+ }
23
+ }
24
+
25
+ /**
26
+ * @param {{ preselected?: string[] }} [opts]
27
+ * @param {{ load?: () => Promise<object> }} [deps] injectable for tests
28
+ */
29
+ export async function pickIdes({ preselected } = {}, { load = loadCheckbox } = {}) {
7
30
  const validIds = new Set(allIdeIds());
8
31
  const isTTY = process.stdin.isTTY && process.stdout.isTTY;
9
32
 
@@ -23,7 +46,7 @@ export async function pickIdes({ preselected } = {}) {
23
46
  warn(`Non-interactive terminal — configuring IDEs: ${chosen.join(', ')}`);
24
47
  return chosen;
25
48
  }
26
- const { default: checkbox, Separator } = await import('@inquirer/checkbox');
49
+ const { default: checkbox, Separator } = await openIdeList(load);
27
50
  const answer = await checkbox({
28
51
  message: 'Which AI IDEs do you use? (space to toggle, enter to confirm)',
29
52
  instructions: false,