@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.
- package/CHANGELOG.md +102 -0
- package/README.md +70 -13
- package/package.json +2 -2
- package/src/cli.js +15 -38
- package/src/commands/init.js +41 -44
- package/src/commands/update.js +78 -75
- package/src/lib/blocos.js +148 -0
- package/src/lib/deteccao.js +69 -0
- package/src/lib/fsx.js +1 -55
- package/src/lib/ides.js +4 -0
- package/src/lib/legado.js +142 -0
- package/src/lib/manifest.js +67 -26
- package/src/lib/mcp.js +131 -0
- package/src/lib/migrations.js +77 -74
- package/src/lib/node-version.js +43 -0
- package/src/lib/prompts.js +25 -2
- package/src/lib/resumo.js +125 -0
- package/templates/.mcp.json +1 -1
- package/templates/AGENTS.md +20 -6
- package/templates/_opencrew/.opencrew-version +1 -1
- package/templates/_opencrew/core/best-practices/social-networks-publishing.md +14 -14
- package/templates/_opencrew/core/escritorio/animacao.js +64 -0
- package/templates/_opencrew/core/escritorio/app.js +137 -0
- package/templates/_opencrew/core/escritorio/cena.js +132 -0
- package/templates/_opencrew/core/escritorio/demo.js +79 -0
- package/templates/_opencrew/core/escritorio/escala.js +27 -0
- package/templates/_opencrew/core/escritorio/index.html +166 -0
- package/templates/_opencrew/core/escritorio/modelo-agentes.js +93 -0
- package/templates/_opencrew/core/escritorio/modelo-estado.js +71 -0
- package/templates/_opencrew/core/escritorio/modelo-mesas.js +81 -0
- package/templates/_opencrew/core/escritorio/modelo-pagina.js +95 -0
- package/templates/_opencrew/core/escritorio/modelo-textos.js +65 -0
- package/templates/_opencrew/core/escritorio/modelo-visao.js +91 -0
- package/templates/_opencrew/core/escritorio/modelo.js +29 -0
- package/templates/_opencrew/core/escritorio/painel.js +120 -0
- package/templates/_opencrew/core/escritorio/quadro.js +106 -0
- package/templates/_opencrew/core/escritorio/rota.js +62 -0
- package/templates/_opencrew/core/escritorio/rotulos.js +78 -0
- package/templates/_opencrew/core/escritorio/sprites-mesa.js +122 -0
- package/templates/_opencrew/core/escritorio/sprites-sala.js +92 -0
- package/templates/_opencrew/core/escritorio/sprites.js +187 -0
- package/templates/_opencrew/core/prompts/build.prompt.md +3 -3
- package/templates/_opencrew/core/prompts/export.prompt.md +1 -1
- package/templates/_opencrew/core/prompts/repair.prompt.md +7 -12
- package/templates/_opencrew/core/prompts/sherlock-shared.md +5 -5
- package/templates/_opencrew/core/runner.pipeline.md +76 -139
- package/templates/_opencrew/core/scripts/comum.mjs +49 -4
- package/templates/_opencrew/core/scripts/conferir-fontes/busca.mjs +42 -3
- package/templates/_opencrew/core/scripts/conferir-fontes/relatorio.mjs +18 -6
- package/templates/_opencrew/core/scripts/conferir-fontes.mjs +97 -39
- package/templates/_opencrew/core/scripts/escritorio/leitura.mjs +31 -0
- package/templates/_opencrew/core/scripts/escritorio/porta.mjs +98 -0
- package/templates/_opencrew/core/scripts/escritorio/projeto.mjs +29 -0
- package/templates/_opencrew/core/scripts/escritorio/servidor.mjs +78 -0
- package/templates/_opencrew/core/scripts/escritorio.mjs +117 -0
- package/templates/_opencrew/core/scripts/estado/argumentos.mjs +61 -0
- package/templates/_opencrew/core/scripts/estado/arquivo.mjs +53 -0
- package/templates/_opencrew/core/scripts/estado/decisao.mjs +56 -0
- package/templates/_opencrew/core/scripts/estado/elenco.mjs +58 -0
- package/templates/_opencrew/core/scripts/estado/nucleo.mjs +113 -0
- package/templates/_opencrew/core/scripts/estado/preferencia.mjs +24 -0
- package/templates/_opencrew/core/scripts/estado.mjs +96 -0
- package/templates/_opencrew/core/scripts/verificar.mjs +7 -4
- package/templates/_opencrew/core/skills.engine.md +7 -3
- package/templates/gitignore +1 -0
- package/templates/skills/blotato/SKILL.md +39 -10
- package/templates/skills/image-ai-generator/SKILL.md +18 -5
- package/templates/skills/image-ai-generator/scripts/generate.py +52 -10
- package/templates/skills/instagram-publisher/SKILL.md +4 -0
- package/templates/skills/opencrew-skill-creator/references/skill-format.md +1 -0
- package/templates/skills/resend/SKILL.md +52 -13
package/src/commands/update.js
CHANGED
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import { templatesDir, packageJsonPath } from '../lib/paths.js';
|
|
4
|
-
import { exists, writeFileSafe, readJson,
|
|
5
|
-
import { AGENTS_BRIDGE, LEAKED_STATUS_SECTION
|
|
6
|
-
import { readManifest, writeManifest, newDelivery, deliverTree, deliverFile } from '../lib/manifest.js';
|
|
7
|
-
import {
|
|
4
|
+
import { exists, writeFileSafe, readJson, readFile } from '../lib/fsx.js';
|
|
5
|
+
import { AGENTS_BRIDGE, LEAKED_STATUS_SECTION } from '../lib/ides.js';
|
|
6
|
+
import { readManifest, manifestUnreadable, UNREADABLE, writeManifest, newDelivery, deliverTree, deliverFile } from '../lib/manifest.js';
|
|
7
|
+
import { deliverBlock, deliverBridges, blockHas } from '../lib/blocos.js';
|
|
8
|
+
import { detectInstalledIdes } from '../lib/deteccao.js';
|
|
9
|
+
import { withoutLegacy } from '../lib/legado.js';
|
|
10
|
+
import { updateMcp } from '../lib/mcp.js';
|
|
11
|
+
import { compareVersions, installedVersion, findLeftovers } from '../lib/migrations.js';
|
|
12
|
+
import { say, recreatedLines, updateSummary, UNTOUCHED } from '../lib/resumo.js';
|
|
8
13
|
import { c, log, info, ok, warn, err, step } from '../lib/ui.js';
|
|
9
14
|
|
|
15
|
+
const tpl = (...p) => path.join(templatesDir, ...p);
|
|
16
|
+
|
|
10
17
|
// `update` brings EVERY improvement to people who already use OpenCrew (AGENTS.md rule 14),
|
|
11
18
|
// without losing what they made:
|
|
12
19
|
// - _opencrew/core and catalog skills are replaced — a file the user edited is copied to
|
|
13
20
|
// .opencrew-backup/<date>/ first (manifest of hashes; none = copy whatever differs);
|
|
14
|
-
// - new framework folders (agents, config, crew templates) arrive without overwriting
|
|
15
|
-
//
|
|
16
|
-
// -
|
|
21
|
+
// - new framework folders (agents, config, crew templates) arrive without overwriting — a
|
|
22
|
+
// crew template the user deleted comes back, and the output lists it;
|
|
23
|
+
// - bridges of the IDEs already installed are refreshed (never new IDEs — an IDE is proved by
|
|
24
|
+
// a file, src/lib/deteccao.js), and so are the opencrew blocks of AGENTS.md and .gitignore
|
|
25
|
+
// — a block the user edited is copied first; the text bridges had up to 1.2.2 leaves the
|
|
26
|
+
// outside of the block, with a copy, only when it is still exactly what was generated;
|
|
27
|
+
// - the Playwright server of .mcp.json is delivered once (the manifest records it) and never
|
|
28
|
+
// put back; the file is copied before any rewrite (src/lib/mcp.js);
|
|
29
|
+
// - the crews the user created, _opencrew/_memory/, _opencrew/best-practices.local/ and .env
|
|
30
|
+
// are never touched.
|
|
31
|
+
// Every step returns what it did, and the summary only says that (src/lib/resumo.js).
|
|
17
32
|
export async function update(opts = {}) {
|
|
18
33
|
const target = process.cwd();
|
|
19
|
-
const
|
|
20
|
-
const version = pkg.version;
|
|
34
|
+
const { version } = await readJson(packageJsonPath);
|
|
21
35
|
|
|
22
36
|
if (!(await exists(path.join(target, '_opencrew', 'core')))) {
|
|
23
37
|
warn('No opencrew workspace found here.');
|
|
@@ -25,15 +39,15 @@ export async function update(opts = {}) {
|
|
|
25
39
|
return;
|
|
26
40
|
}
|
|
27
41
|
|
|
28
|
-
const
|
|
29
|
-
const current = (await exists(versionFile))
|
|
30
|
-
? (await fs.readFile(versionFile, 'utf8')).trim()
|
|
31
|
-
: 'unknown';
|
|
32
|
-
const newer = current !== 'unknown' && compareVersions(current, version) > 0;
|
|
33
|
-
|
|
42
|
+
const current = (await installedVersion(target)) ?? 'unknown';
|
|
34
43
|
log(`\n${c.bold(c.cyan('opencrew update'))}`);
|
|
35
44
|
log(c.dim(`Installed: ${current} → Package: ${version}\n`));
|
|
45
|
+
if (canApply(opts, current, version)) await apply(target, version);
|
|
46
|
+
}
|
|
36
47
|
|
|
48
|
+
/** `--check` only reports, and a package older than the workspace stops: false = write nothing. */
|
|
49
|
+
function canApply(opts, current, version) {
|
|
50
|
+
const newer = current !== 'unknown' && compareVersions(current, version) > 0;
|
|
37
51
|
if (opts.check) {
|
|
38
52
|
if (current === version) ok(`Up to date (v${version}).`);
|
|
39
53
|
else if (newer) info(`A versão instalada (v${current}) é mais nova que este pacote (v${version}).`);
|
|
@@ -42,82 +56,82 @@ export async function update(opts = {}) {
|
|
|
42
56
|
info(`Run ${c.cyan('npx @aksp/opencrew update')} to apply.`);
|
|
43
57
|
process.exitCode = 1;
|
|
44
58
|
}
|
|
45
|
-
return;
|
|
59
|
+
return false;
|
|
46
60
|
}
|
|
47
|
-
|
|
48
61
|
if (newer) {
|
|
49
62
|
err(`Você tem a v${current} instalada e este pacote é a v${version} (mais antigo). Nada foi alterado.`);
|
|
50
63
|
info(`Use ${c.cyan('npx @aksp/opencrew@latest update')}.`);
|
|
51
64
|
process.exitCode = 1;
|
|
52
|
-
return;
|
|
53
65
|
}
|
|
66
|
+
return !newer;
|
|
67
|
+
}
|
|
54
68
|
|
|
69
|
+
async function apply(target, version) {
|
|
70
|
+
// A manifest that exists but cannot be used is said so, then treated as none (R2 rule 12).
|
|
55
71
|
const manifest = await readManifest(target);
|
|
72
|
+
const unreadable = !manifest && (await manifestUnreadable(target));
|
|
73
|
+
if (unreadable) warn(UNREADABLE.update);
|
|
56
74
|
const ctx = newDelivery(target, manifest);
|
|
57
|
-
const tpl = (...p) => path.join(templatesDir, ...p);
|
|
58
|
-
const dest = (...p) => path.join(target, ...p);
|
|
59
75
|
|
|
60
76
|
step('Refreshing framework');
|
|
61
|
-
|
|
62
|
-
await deliverFile(ctx, dest('_opencrew', 'core', 'system.md'), await fs.readFile(tpl('AGENTS.md')), { overwrite: true });
|
|
63
|
-
await deliverTree(ctx, tpl('skills'), dest('skills'), { overwrite: true });
|
|
64
|
-
// New framework folders (e.g. base agents since 1.3.2): only what is missing.
|
|
65
|
-
for (const dir of ['agents', 'config', '_investigations']) {
|
|
66
|
-
await deliverTree(ctx, tpl('_opencrew', dir), dest('_opencrew', dir), { overwrite: false });
|
|
67
|
-
}
|
|
68
|
-
await deliverTree(ctx, tpl('crews'), dest('crews'), { overwrite: false });
|
|
77
|
+
const crews = await refreshFramework(ctx);
|
|
69
78
|
ok(`Framework and catalog skills refreshed (${ctx.written} files written)`);
|
|
79
|
+
say(recreatedLines(crews));
|
|
70
80
|
|
|
71
81
|
step('Refreshing IDE bridges');
|
|
72
|
-
await
|
|
73
|
-
|
|
74
|
-
await
|
|
75
|
-
|
|
76
|
-
await removeLeakedStatusSection(target);
|
|
77
|
-
|
|
78
|
-
const mcp = await mergeMcp(target, tpl('.mcp.json'));
|
|
79
|
-
if (mcp === 'updated' || mcp === 'created') ok(`.mcp.json (Playwright: ${mcp === 'created' ? 'created' : 'saída em _opencrew/logs/playwright/'})`);
|
|
80
|
-
if (mcp === 'invalid') warn('.mcp.json não é um JSON válido — não alterado. Confira o arquivo.');
|
|
81
|
-
|
|
82
|
-
for (const legacy of await findLegacyBridges(target)) {
|
|
83
|
-
warn(`Ponte antiga encontrada: ${legacy} (aponta para _opensquad/, que não existe neste projeto). Pode apagar com segurança.`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (ctx.copied.length) {
|
|
87
|
-
const rel = path.relative(target, ctx.backupDir).split(path.sep).join('/');
|
|
88
|
-
const what = manifest ? 'que você tinha editado' : 'diferentes do pacote novo';
|
|
89
|
-
warn(`${ctx.copied.length} arquivo(s) ${what} foram copiados para ${rel}/ antes de serem substituídos:`);
|
|
90
|
-
for (const f of ctx.copied.slice(0, 15)) log(` ${f}`);
|
|
91
|
-
if (ctx.copied.length > 15) log(` … e mais ${ctx.copied.length - 15}`);
|
|
92
|
-
if (!manifest) info('Primeira atualização com proteção: sem registro anterior, guardamos tudo o que diferia. Daqui em diante, só o que você editar.');
|
|
93
|
-
}
|
|
82
|
+
const done = { unreadable, ...(await refreshBridges(ctx)) };
|
|
83
|
+
done.mcp = await updateMcp(ctx, await readFile(tpl('.mcp.json')));
|
|
84
|
+
done.leftovers = await findLeftovers(target);
|
|
85
|
+
say(updateSummary(ctx, done));
|
|
94
86
|
|
|
95
87
|
await writeManifest(target, version, ctx.files);
|
|
96
88
|
// Stamp last: a crash above leaves the old version, so the next update retries.
|
|
97
|
-
await fs.writeFile(
|
|
89
|
+
await fs.writeFile(path.join(target, '_opencrew', '.opencrew-version'), version + '\n');
|
|
98
90
|
log(`\n${c.green(c.bold('Updated to v' + version))}.`);
|
|
99
|
-
log(c.dim(
|
|
91
|
+
log(c.dim(`${UNTOUCHED}\n`));
|
|
100
92
|
}
|
|
101
93
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
94
|
+
/** @returns what `deliverTree` did to each crew template (only the missing ones are written). */
|
|
95
|
+
async function refreshFramework(ctx) {
|
|
96
|
+
const dest = (...p) => path.join(ctx.target, ...p);
|
|
97
|
+
await deliverTree(ctx, tpl('_opencrew', 'core'), dest('_opencrew', 'core'), { overwrite: true });
|
|
98
|
+
await deliverFile(ctx, dest('_opencrew', 'core', 'system.md'), await fs.readFile(tpl('AGENTS.md')), { overwrite: true });
|
|
99
|
+
await deliverTree(ctx, tpl('skills'), dest('skills'), { overwrite: true });
|
|
100
|
+
// New framework folders (e.g. base agents since 1.3.2): only what is missing.
|
|
101
|
+
for (const dir of ['agents', 'config', '_investigations']) {
|
|
102
|
+
await deliverTree(ctx, tpl('_opencrew', dir), dest('_opencrew', dir), { overwrite: false });
|
|
110
103
|
}
|
|
111
|
-
|
|
104
|
+
return deliverTree(ctx, tpl('crews'), dest('crews'), { overwrite: false });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** AGENTS.md, .gitignore and the bridges of the IDEs installed: what happened to each. */
|
|
108
|
+
async function refreshBridges(ctx) {
|
|
109
|
+
const agents = await refreshAgentsBridge(ctx);
|
|
110
|
+
// The copies in .opencrew-backup/ stay out of the user's git (R2 rule 7).
|
|
111
|
+
const gitignore = await deliverBlock(ctx, '.gitignore', await readFile(tpl('gitignore')));
|
|
112
|
+
const ides = await detectInstalledIdes(ctx.target);
|
|
113
|
+
// 1.4.0/1.4.1 shipped the maintainer's STATUS.md workflow inside CLAUDE.md's opencrew block.
|
|
114
|
+
// It leaves with the block; the same title outside the block is the user's and stays.
|
|
115
|
+
const leaked = () => blockHas(ctx.target, 'CLAUDE.md', LEAKED_STATUS_SECTION);
|
|
116
|
+
const hadLeak = await leaked();
|
|
117
|
+
const bridges = await withoutLegacy(ctx, ides, () => deliverBridges(ctx, ides)); // R2 rule 3 (src/lib/legado.js)
|
|
118
|
+
return { agents, gitignore, ides, bridges, leak: hadLeak && !(await leaked()) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Root AGENTS.md: a legacy full-system doc (pre-v1.3) is backed up byte for byte and replaced by
|
|
122
|
+
// the thin bridge (said here; returns null); otherwise only the marked block is delivered (a
|
|
123
|
+
// block the user edited is copied first — src/lib/blocos.js) and its result goes to the summary.
|
|
124
|
+
async function refreshAgentsBridge(ctx) {
|
|
125
|
+
const agentsPath = path.join(ctx.target, 'AGENTS.md');
|
|
126
|
+
const existing = (await exists(agentsPath)) ? await readFile(agentsPath) : '';
|
|
112
127
|
if (existing.includes('# opencrew Instructions') && !existing.includes('<!-- opencrew:start -->')) {
|
|
113
128
|
const backup = await freeBackupPath(agentsPath);
|
|
114
129
|
await fs.copyFile(agentsPath, backup);
|
|
115
130
|
await writeFileSafe(agentsPath, AGENTS_BRIDGE);
|
|
116
131
|
ok(`AGENTS.md (migrated from legacy full-system to thin bridge — backed up to ${path.basename(backup)})`);
|
|
117
|
-
return;
|
|
132
|
+
return null;
|
|
118
133
|
}
|
|
119
|
-
|
|
120
|
-
ok('AGENTS.md refreshed');
|
|
134
|
+
return deliverBlock(ctx, 'AGENTS.md', AGENTS_BRIDGE);
|
|
121
135
|
}
|
|
122
136
|
|
|
123
137
|
async function freeBackupPath(file) {
|
|
@@ -125,14 +139,3 @@ async function freeBackupPath(file) {
|
|
|
125
139
|
if (!(await exists(bak))) return bak;
|
|
126
140
|
return `${file}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
127
141
|
}
|
|
128
|
-
|
|
129
|
-
// 1.4.0/1.4.1 shipped the maintainer's STATUS.md workflow inside CLAUDE.md's opencrew
|
|
130
|
-
// block. Rewrite that block (only that block, only if the leak is there).
|
|
131
|
-
async function removeLeakedStatusSection(target) {
|
|
132
|
-
const claudePath = path.join(target, 'CLAUDE.md');
|
|
133
|
-
if (!(await exists(claudePath))) return;
|
|
134
|
-
if (!(await readFile(claudePath)).includes(LEAKED_STATUS_SECTION)) return;
|
|
135
|
-
const bridge = ideById('claude-code').files.find((f) => f.path === 'CLAUDE.md');
|
|
136
|
-
await writeBridgeFile(claudePath, bridge.content);
|
|
137
|
-
ok('CLAUDE.md (removed the STATUS.md section shipped by mistake in 1.4.0/1.4.1)');
|
|
138
|
-
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Marked blocks (R2 rule 4). `deliverBlock` is the ONE writer of every `opencrew:start … end`
|
|
2
|
+
// block — IDE bridges, AGENTS.md, .gitignore, .env.example — for init, update and repair.
|
|
3
|
+
// The manifest records each block (`<file>#opencrew` → sha256 of the block, markers included,
|
|
4
|
+
// with LF), so a block the user edited is told apart from one that is just older: the whole
|
|
5
|
+
// file is copied to .opencrew-backup/ before a block that differs from its record (or has
|
|
6
|
+
// none) is rewritten. CRLF/LF is never a difference, and what is written follows the file.
|
|
7
|
+
import { promises as fs } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { exists } from './fsx.js';
|
|
10
|
+
import { deliverFile, backupFile, hashOf } from './manifest.js';
|
|
11
|
+
|
|
12
|
+
// The file is handled byte by byte (latin1 goes there and back unchanged): whatever its encoding,
|
|
13
|
+
// no byte of the user outside the block changes. `bytesOf` = UTF-8 text in that same form.
|
|
14
|
+
const bytesOf = (text) => Buffer.from(text, 'utf8').toString('latin1');
|
|
15
|
+
const BOM = bytesOf(String.fromCharCode(0xfeff));
|
|
16
|
+
const isUtf16 = (raw) => (raw[0] === 0xff && raw[1] === 0xfe) || (raw[0] === 0xfe && raw[1] === 0xff);
|
|
17
|
+
|
|
18
|
+
// Files of the user where the block goes at the END, between `#` comment markers.
|
|
19
|
+
const AT_END = new Set(['.gitignore', '.env.example']);
|
|
20
|
+
const ONLY_THE_BLOCK = '(só o bloco do OpenCrew foi regravado; o resto do arquivo não mudou)';
|
|
21
|
+
|
|
22
|
+
/** Key of a block in the manifest (`file` = path relative to the project root, with `/`). */
|
|
23
|
+
export const blockKey = (file) => `${file}#opencrew`;
|
|
24
|
+
|
|
25
|
+
const markersOf = (file) => (AT_END.has(file)
|
|
26
|
+
? ['# opencrew:start', '# opencrew:end']
|
|
27
|
+
: ['<!-- opencrew:start -->', '<!-- opencrew:end -->']);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Complete blocks of `text`, as [from, to) ranges. Innermost start only: an orphan start
|
|
31
|
+
* marker (its end deleted by hand) never makes a block swallow the user lines after it.
|
|
32
|
+
*/
|
|
33
|
+
export function blockRanges(text, file) {
|
|
34
|
+
const [start, end] = markersOf(file);
|
|
35
|
+
const found = [];
|
|
36
|
+
let pos = 0;
|
|
37
|
+
for (let e = text.indexOf(end); e !== -1; e = text.indexOf(end, pos)) {
|
|
38
|
+
const s = text.lastIndexOf(start, e - start.length);
|
|
39
|
+
if (s >= pos && s + start.length <= e) found.push([s, e + end.length]);
|
|
40
|
+
pos = e + end.length;
|
|
41
|
+
}
|
|
42
|
+
return found;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True when a marked block of `file` contains `needle`; text outside the blocks never counts. */
|
|
46
|
+
export async function blockHas(target, file, needle) {
|
|
47
|
+
const dest = path.join(target, file);
|
|
48
|
+
if (!(await exists(dest))) return false;
|
|
49
|
+
const text = await fs.readFile(dest, 'utf8');
|
|
50
|
+
return blockRanges(text, file).some(([from, to]) => text.slice(from, to).includes(needle));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function replaceRanges(text, ranges, block) {
|
|
54
|
+
let out = '';
|
|
55
|
+
let pos = 0;
|
|
56
|
+
for (const [from, to] of ranges) {
|
|
57
|
+
out += text.slice(pos, from) + block;
|
|
58
|
+
pos = to;
|
|
59
|
+
}
|
|
60
|
+
return out + text.slice(pos);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What delivering `marked` does to a file whose content is `text` (null = no file):
|
|
65
|
+
* the action, the new content (none when kept) and the hash of each block found.
|
|
66
|
+
*/
|
|
67
|
+
function planBlock(file, text, marked) {
|
|
68
|
+
if (text === null) return { action: 'created', text: `${bytesOf(marked)}\n`, hashes: [] };
|
|
69
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
70
|
+
const block = bytesOf(marked).replace(/\n/g, eol);
|
|
71
|
+
const ranges = blockRanges(text, file);
|
|
72
|
+
if (!ranges.length) {
|
|
73
|
+
const bom = text.startsWith(BOM) ? BOM : '';
|
|
74
|
+
const added = AT_END.has(file)
|
|
75
|
+
? `${text.replace(/[ \t\r\n]+$/, '')}${eol}${eol}${block}${eol}`
|
|
76
|
+
: `${bom}${block}${eol}${eol}${text.slice(bom.length).replace(/^[ \t\r\n]+/, '')}`;
|
|
77
|
+
return { action: 'added', text: added, hashes: [] };
|
|
78
|
+
}
|
|
79
|
+
const hashes = ranges.map(([from, to]) => hashOf(Buffer.from(text.slice(from, to), 'latin1').toString('utf8')));
|
|
80
|
+
if (hashes.every((h) => h === hashOf(marked))) return { action: 'kept', hashes };
|
|
81
|
+
return { action: 'updated', text: replaceRanges(text, ranges, block), hashes };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Put `content` between the opencrew markers of `file` (path relative to the project root,
|
|
86
|
+
* with `/`) and record the block in `ctx.files`.
|
|
87
|
+
* - no file → `created`; file with no block → `added` at the top (at the end in .gitignore
|
|
88
|
+
* and .env.example), the user's content kept, no copy;
|
|
89
|
+
* - block equal to the new one → `kept`, nothing written;
|
|
90
|
+
* - otherwise → `updated`: only the block is rewritten, in the line ending of the file. The
|
|
91
|
+
* whole file is copied first unless the block is exactly what the manifest recorded.
|
|
92
|
+
* @returns {Promise<{file:string, action:'created'|'added'|'updated'|'kept', copied:boolean, block:true}>}
|
|
93
|
+
* also pushed to `ctx.blocks`. `copied` = this call made the backup copy.
|
|
94
|
+
*/
|
|
95
|
+
export async function deliverBlock(ctx, file, content) {
|
|
96
|
+
const [start, end] = markersOf(file);
|
|
97
|
+
const marked = `${start}\n${content.replace(/\r\n/g, '\n').trimEnd()}\n${end}`;
|
|
98
|
+
const dest = path.join(ctx.target, file);
|
|
99
|
+
const raw = (await exists(dest)) ? await fs.readFile(dest) : null;
|
|
100
|
+
const plan = planBlock(file, raw && raw.toString('latin1'), marked);
|
|
101
|
+
const record = ctx.manifest?.files?.[blockKey(file)];
|
|
102
|
+
const edited = plan.action === 'updated' && !plan.hashes.every((h) => h === record);
|
|
103
|
+
// A UTF-16 file cannot take a UTF-8 block without damage: the whole file is copied first.
|
|
104
|
+
const risky = raw && plan.text !== undefined && isUtf16(raw);
|
|
105
|
+
const copied = (edited || risky) && (await backupFile(ctx, file));
|
|
106
|
+
if (plan.text !== undefined) {
|
|
107
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
108
|
+
await fs.writeFile(dest, Buffer.from(plan.text, 'latin1'));
|
|
109
|
+
}
|
|
110
|
+
ctx.files[blockKey(file)] = hashOf(marked);
|
|
111
|
+
const result = { file, action: plan.action, copied, block: true };
|
|
112
|
+
ctx.blocks.push(result);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Write the bridge files of `ides`, each path once: a file with frontmatter is delivered whole
|
|
118
|
+
* (replaced only when `overwrite`), the others by marked block.
|
|
119
|
+
* @returns {Promise<Array<{ide:object, file:string, action:string, copied:boolean, block?:true, shared?:true}>>}
|
|
120
|
+
* one entry per file of each IDE, in order. `shared` = the path had already been written for
|
|
121
|
+
* an earlier IDE of the list (same action; count copies by `ctx.copied`, not by these entries).
|
|
122
|
+
*/
|
|
123
|
+
export async function deliverBridges(ctx, ides, { overwrite = true } = {}) {
|
|
124
|
+
const byPath = new Map();
|
|
125
|
+
const results = [];
|
|
126
|
+
for (const ide of ides) {
|
|
127
|
+
for (const f of ide.files) {
|
|
128
|
+
if (byPath.has(f.path)) {
|
|
129
|
+
results.push({ ide, ...byPath.get(f.path), shared: true });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const result = f.content.startsWith('---')
|
|
133
|
+
? await deliverFile(ctx, path.join(ctx.target, f.path), f.content, { overwrite })
|
|
134
|
+
: await deliverBlock(ctx, f.path, f.content);
|
|
135
|
+
byPath.set(f.path, result);
|
|
136
|
+
results.push({ ide, ...result });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return results;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Line of a list of backup copies: a file copied because of its block says that only the
|
|
144
|
+
* block was rewritten. `shown` = how the caller prints the file (default: its path).
|
|
145
|
+
*/
|
|
146
|
+
export function copyLine(ctx, file, shown = file) {
|
|
147
|
+
return ctx.blocks.some((b) => b.copied && b.file === file) ? `${shown} ${ONLY_THE_BLOCK}` : shown;
|
|
148
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Which IDEs are installed in a project (spec R2, rules 1 and 2) — shared by `update` and
|
|
2
|
+
// `init --repair-bridges`. An IDE is proved by a FILE, never by the word "opencrew" somewhere:
|
|
3
|
+
// (a) a bridge file that only this IDE writes, on a path that has "opencrew" in it: it exists;
|
|
4
|
+
// (b) an instruction file the IDE shares with the user (no "opencrew" in the path): it has
|
|
5
|
+
// the start marker of the block, or the line of the title OpenCrew generates in it.
|
|
6
|
+
// An IDE with no file of its own (today, the Codex) counts only when its file is there and no
|
|
7
|
+
// detected IDE writes the same path. A user file that only cites the word proves nothing.
|
|
8
|
+
import { promises as fs } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { exists } from './fsx.js';
|
|
11
|
+
import { IDES } from './ides.js';
|
|
12
|
+
|
|
13
|
+
const START = '<!-- opencrew:start -->';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The instruction files shared with the user and the title generated in each since 1.0.0.
|
|
17
|
+
* Fixed here on purpose, never read from today's bridge content: a title changed in ides.js
|
|
18
|
+
* must not hide the installs made by the versions that wrote the old one (up to 1.2.2 the
|
|
19
|
+
* file had no marker — the title is all there is). Pinned by R2-01d in tests/ides.test.js.
|
|
20
|
+
*/
|
|
21
|
+
export const USER_FILE_TITLES = Object.freeze({
|
|
22
|
+
'CLAUDE.md': '# opencrew — Project Instructions',
|
|
23
|
+
'.github/copilot-instructions.md': '# opencrew — Copilot Instructions',
|
|
24
|
+
'GEMINI.md': '# opencrew — Gemini CLI',
|
|
25
|
+
'QWEN.md': '# opencrew — Qwen Code',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const BOM = String.fromCharCode(0xfeff);
|
|
29
|
+
|
|
30
|
+
/** A line of a file as OpenCrew compares it: no BOM in front, no CR at the end. */
|
|
31
|
+
export function plainLine(raw) {
|
|
32
|
+
const line = raw.startsWith(BOM) ? raw.slice(1) : raw;
|
|
33
|
+
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const writersOf = (file) => IDES.filter((ide) => ide.files.some((f) => f.path === file));
|
|
37
|
+
const ownFiles = (ide) => ide.files.filter((f) => writersOf(f.path).length === 1);
|
|
38
|
+
|
|
39
|
+
/** True when the bridge file `file` (path relative to the root, with `/`) proves its IDE. */
|
|
40
|
+
async function proves(target, file) {
|
|
41
|
+
const dest = path.join(target, file);
|
|
42
|
+
if (!(await exists(dest))) return false;
|
|
43
|
+
if (file.includes('opencrew')) return true;
|
|
44
|
+
const text = await fs.readFile(dest, 'utf8');
|
|
45
|
+
return text.includes(START) || text.split('\n').map(plainLine).includes(USER_FILE_TITLES[file]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function provesAny(target, files) {
|
|
49
|
+
for (const f of files) if (await proves(target, f.path)) return true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* IDEs installed in `target`, in the order of `IDES`.
|
|
55
|
+
* @param {string} target project root
|
|
56
|
+
* @returns {Promise<Array<object>>} entries of `IDES`
|
|
57
|
+
*/
|
|
58
|
+
export async function detectInstalledIdes(target) {
|
|
59
|
+
const byOwnFile = [];
|
|
60
|
+
for (const ide of IDES) if (await provesAny(target, ownFiles(ide))) byOwnFile.push(ide);
|
|
61
|
+
// Rule 2: a path that a detected IDE writes proves nothing about an IDE with no own file.
|
|
62
|
+
const free = (f) => !writersOf(f.path).some((ide) => byOwnFile.includes(ide));
|
|
63
|
+
const found = [];
|
|
64
|
+
for (const ide of IDES) {
|
|
65
|
+
const alone = !ownFiles(ide).length && (await provesAny(target, ide.files.filter(free)));
|
|
66
|
+
if (alone || byOwnFile.includes(ide)) found.push(ide);
|
|
67
|
+
}
|
|
68
|
+
return found;
|
|
69
|
+
}
|
package/src/lib/fsx.js
CHANGED
|
@@ -50,61 +50,7 @@ export async function writeFileSafe(p, content, { overwrite = true } = {}) {
|
|
|
50
50
|
return true;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
* Write a bridge file using marked-block strategy.
|
|
55
|
-
* - File doesn't exist → creates with content wrapped in HTML markers
|
|
56
|
-
* - File exists with markers → replaces only the block between markers
|
|
57
|
-
* - File exists without markers → adds the marked block (prepend or append), preserving
|
|
58
|
-
* existing content byte for byte
|
|
59
|
-
*
|
|
60
|
-
* @param {string} p File path
|
|
61
|
-
* @param {string} block Content to place between markers
|
|
62
|
-
* @param {object} opts
|
|
63
|
-
* @param {string} opts.marker Marker name (default: 'opencrew')
|
|
64
|
-
* @param {'html'|'hash'} opts.comment `<!-- x:start -->` (markdown) or `# x:start` (.gitignore, .env)
|
|
65
|
-
* @param {'prepend'|'append'} opts.position Where a new block goes in an unmarked file
|
|
66
|
-
* @returns {Promise<{written: boolean, merged: boolean}>}
|
|
67
|
-
*/
|
|
68
|
-
export async function writeBridgeFile(p, block, { marker = 'opencrew', comment = 'html', position = 'prepend' } = {}) {
|
|
69
|
-
const [start, end] = comment === 'hash'
|
|
70
|
-
? [`# ${marker}:start`, `# ${marker}:end`]
|
|
71
|
-
: [`<!-- ${marker}:start -->`, `<!-- ${marker}:end -->`];
|
|
72
|
-
const marked = `${start}\n${block.trimEnd()}\n${end}`;
|
|
73
|
-
|
|
74
|
-
if (!(await exists(p))) {
|
|
75
|
-
await ensureDir(path.dirname(p));
|
|
76
|
-
await fs.writeFile(p, marked + '\n');
|
|
77
|
-
return { written: true, merged: false };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const existing = await fs.readFile(p, 'utf8');
|
|
81
|
-
|
|
82
|
-
// Already has markers → replace just the block, keep everything else
|
|
83
|
-
if (existing.includes(start) && existing.includes(end)) {
|
|
84
|
-
// Innermost block only: an orphan start marker (end deleted by hand) must never make
|
|
85
|
-
// the match swallow the user lines between it and the real block.
|
|
86
|
-
const updated = existing.replace(
|
|
87
|
-
new RegExp(`${escapeRx(start)}(?:(?!${escapeRx(start)})[\\s\\S])*?${escapeRx(end)}`, 'g'),
|
|
88
|
-
() => marked,
|
|
89
|
-
);
|
|
90
|
-
if (updated !== existing) {
|
|
91
|
-
await fs.writeFile(p, updated);
|
|
92
|
-
return { written: true, merged: false };
|
|
93
|
-
}
|
|
94
|
-
return { written: false, merged: false };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// User content exists → add the block, preserve everything
|
|
98
|
-
const merged = position === 'append'
|
|
99
|
-
? `${existing.trimEnd()}\n\n${marked}\n`
|
|
100
|
-
: `${marked}\n\n${existing.trimStart()}`;
|
|
101
|
-
await fs.writeFile(p, merged);
|
|
102
|
-
return { written: true, merged: true };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function escapeRx(s) {
|
|
106
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
107
|
-
}
|
|
53
|
+
// Marked blocks (`opencrew:start … opencrew:end`) are written by `deliverBlock`, in ./blocos.js.
|
|
108
54
|
|
|
109
55
|
export async function readFile(p) {
|
|
110
56
|
return fs.readFile(p, 'utf8');
|
package/src/lib/ides.js
CHANGED
|
@@ -56,6 +56,10 @@ export const AGENTS_BRIDGE = `# opencrew\n\n${ACTIVATION}\n\nType \`/opencrew\`
|
|
|
56
56
|
// Marker that identifies the maintainer STATUS.md section leaked into CLAUDE.md by 1.4.0/1.4.1.
|
|
57
57
|
export const LEAKED_STATUS_SECTION = '## STATUS.md (gestão de sessão)';
|
|
58
58
|
|
|
59
|
+
// The title of CLAUDE.md, GEMINI.md, QWEN.md and copilot-instructions.md (files shared with
|
|
60
|
+
// the user) is how `update` recognizes a bridge written up to 1.2.2, which has no marker. The
|
|
61
|
+
// four titles are fixed in ./deteccao.js and pinned by R2-01d (tests/ides.test.js): to change
|
|
62
|
+
// one here, keep the old one there.
|
|
59
63
|
const render = (title, extra = '') =>
|
|
60
64
|
`# ${title}\n\n${BRIDGE}${extra ? `\n\n${extra}` : ''}\n`;
|
|
61
65
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Old text of the bridges (spec R2, rule 3). Up to 1.2.2 the five bridges with no frontmatter
|
|
2
|
+
// were written as WHOLE files, telling the AI to adopt the OpenCrew role always. Since 1.3.0
|
|
3
|
+
// the bridge lives in a marked block, and a file that kept the old text outside the block
|
|
4
|
+
// keeps giving that order. `update` and `init --repair-bridges` take it out — only when it
|
|
5
|
+
// is exactly what was generated (CRLF tolerated), and after copying the whole file. An edited
|
|
6
|
+
// one (only the title or the "adopt" sentence is left) is never touched: it is reported.
|
|
7
|
+
// `src/lib/ides.js` wrote ONE text per file from v1.0.0 to v1.2.2; they are fixed below.
|
|
8
|
+
import { promises as fs } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { exists } from './fsx.js';
|
|
11
|
+
import { backupFile } from './manifest.js';
|
|
12
|
+
import { blockRanges } from './blocos.js';
|
|
13
|
+
import { USER_FILE_TITLES, plainLine } from './deteccao.js';
|
|
14
|
+
|
|
15
|
+
const OLD_BRIDGE = `Read \`AGENTS.md\` at the project root and adopt the opencrew system role.
|
|
16
|
+
Follow all initialization, command routing, and workflow instructions defined there.
|
|
17
|
+
|
|
18
|
+
If invoked with arguments (e.g. \`/opencrew create ...\`, \`/opencrew run ...\`),
|
|
19
|
+
route to the matching action from the Command Routing table in AGENTS.md.
|
|
20
|
+
If invoked without arguments, show the Main Menu.`;
|
|
21
|
+
|
|
22
|
+
const OLD_CLAUDE_MD = `This project uses **opencrew**, a multi-agent orchestration framework.
|
|
23
|
+
The full system definition lives in \`AGENTS.md\` — read it and adopt that role.
|
|
24
|
+
|
|
25
|
+
Type \`/opencrew\` to open the main menu.
|
|
26
|
+
|
|
27
|
+
## Notes for Claude Code
|
|
28
|
+
|
|
29
|
+
- All checkpoint questions use \`AskUserQuestion\`.
|
|
30
|
+
- opencrew ships its own Playwright MCP (\`.mcp.json\`); disable the native Playwright plugin.
|
|
31
|
+
- Do not manually edit files under \`_opencrew/core/\` unless you know what you're doing.`;
|
|
32
|
+
|
|
33
|
+
const TITLES = { ...USER_FILE_TITLES, '.trae/rules/opencrew.md': '# opencrew — Trae' };
|
|
34
|
+
|
|
35
|
+
// path → the lines of the old text of that file: title, blank line, body.
|
|
36
|
+
const OLD_LINES = Object.fromEntries(Object.entries(TITLES).map(([file, title]) =>
|
|
37
|
+
[file, [title, '', ...(file === 'CLAUDE.md' ? OLD_CLAUDE_MD : OLD_BRIDGE).split('\n')]]));
|
|
38
|
+
|
|
39
|
+
const REMOVED = (file, copy) => `${file}: removi o texto antigo do OpenCrew (instalações até a 1.2.2), que mandava adotar o papel do OpenCrew sempre. O bloco novo ficou no lugar e o resto do arquivo não mudou. Cópia em ${copy}.`;
|
|
40
|
+
const EDITED = (file) => `${file} ainda tem um texto antigo do OpenCrew, alterado depois da instalação, que manda adotar o papel do OpenCrew sempre. Não mexi nele. Para o OpenCrew só agir quando chamado, apague à mão o trecho que começa em \`# opencrew — …\` fora do bloco \`opencrew:start\` / \`opencrew:end\`.`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Lines of `text` — where each starts and ends (line break included), its content with no
|
|
44
|
+
* BOM and no CR, and whether it is outside every marked block of `file`.
|
|
45
|
+
*/
|
|
46
|
+
function linesOf(text, file) {
|
|
47
|
+
const blocks = blockRanges(text, file);
|
|
48
|
+
const lines = [];
|
|
49
|
+
let from = 0;
|
|
50
|
+
for (const raw of text.split('\n')) {
|
|
51
|
+
const to = Math.min(from + raw.length + 1, text.length);
|
|
52
|
+
const outside = !blocks.some(([start, end]) => from < end && to > start);
|
|
53
|
+
lines.push({ from, to, outside, text: plainLine(raw) });
|
|
54
|
+
from = to;
|
|
55
|
+
}
|
|
56
|
+
return lines;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** [from, to) of the first exact copy of the old text of `file` outside the blocks, or null. */
|
|
60
|
+
function findExact(text, file) {
|
|
61
|
+
const old = OLD_LINES[file];
|
|
62
|
+
const lines = linesOf(text, file);
|
|
63
|
+
for (let i = 0; i + old.length <= lines.length; i++) {
|
|
64
|
+
const run = lines.slice(i, i + old.length);
|
|
65
|
+
if (run.every((line, k) => line.outside && line.text === old[k])) return [run[0].from, run.at(-1).to];
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `text` up to its last character that is not a line break. */
|
|
71
|
+
function withoutEndBreaks(text) {
|
|
72
|
+
let end = text.length;
|
|
73
|
+
while (end > 0 && (text[end - 1] === '\n' || text[end - 1] === '\r')) end -= 1;
|
|
74
|
+
return text.slice(0, end);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `text` with every exact copy of the old text of `file` taken out; the rest stays byte for
|
|
79
|
+
* byte. Old text that was the end of the file takes the blank lines above it along: the file
|
|
80
|
+
* ends where the text before it ends (as a new install, when only the block is left).
|
|
81
|
+
*/
|
|
82
|
+
function strip(text, file) {
|
|
83
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
84
|
+
let out = text;
|
|
85
|
+
for (let cut = findExact(out, file); cut; cut = findExact(out, file)) {
|
|
86
|
+
const [before, after] = [out.slice(0, cut[0]), out.slice(cut[1])];
|
|
87
|
+
out = after.trim() ? before + after : `${withoutEndBreaks(before)}${eol}`;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** True when the title or the "adopt" sentence of the old text of `file` is outside the blocks. */
|
|
93
|
+
function hasTraces(text, file) {
|
|
94
|
+
const [title] = OLD_LINES[file];
|
|
95
|
+
const adopt = OLD_LINES[file].find((line) => line.includes('adopt'));
|
|
96
|
+
return linesOf(text, file).some((line) => line.outside && (line.text === title || line.text.includes(adopt)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Take the old text out of `file`, or just copy the file when `write` is false. A file in
|
|
101
|
+
* which only traces of the old text are left is not touched: it goes to `ctx.legacy`.
|
|
102
|
+
*/
|
|
103
|
+
async function clean(ctx, file, write) {
|
|
104
|
+
const dest = path.join(ctx.target, file);
|
|
105
|
+
if (!(await exists(dest))) return;
|
|
106
|
+
const text = await fs.readFile(dest, 'utf8');
|
|
107
|
+
const stripped = strip(text, file);
|
|
108
|
+
if (stripped !== text) await backupFile(ctx, file); // once per run: the file as it was
|
|
109
|
+
if (!write) return;
|
|
110
|
+
if (stripped !== text) {
|
|
111
|
+
await fs.writeFile(dest, stripped);
|
|
112
|
+
ctx.legacy.push({ file, action: 'removed' });
|
|
113
|
+
}
|
|
114
|
+
if (hasTraces(stripped, file)) ctx.legacy.push({ file, action: 'edited' });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run `write` — what delivers the bridges of `ides` (`deliverBridges`, ./blocos.js) — with
|
|
119
|
+
* rule 3 around it. Before: a file with the exact old text outside its block is copied whole,
|
|
120
|
+
* so the copy holds the file as it was and the block written next does not claim that copy as
|
|
121
|
+
* "only the block was rewritten". After, with the new block in place: the old text leaves.
|
|
122
|
+
* What happened to each file goes to `ctx.legacy` (`{file, action: 'removed' | 'edited'}`).
|
|
123
|
+
* @param {object} ctx delivery context (`newDelivery`, ./manifest.js)
|
|
124
|
+
* @param {Array<object>} ides entries of `IDES` whose bridges `write` delivers
|
|
125
|
+
* @param {() => Promise<T>} write
|
|
126
|
+
* @returns {Promise<T>} what `write` returned
|
|
127
|
+
* @template T
|
|
128
|
+
*/
|
|
129
|
+
export async function withoutLegacy(ctx, ides, write) {
|
|
130
|
+
const paths = new Set(ides.flatMap((ide) => ide.files.map((f) => f.path)));
|
|
131
|
+
const files = [...paths].filter((file) => OLD_LINES[file]);
|
|
132
|
+
for (const file of files) await clean(ctx, file, false);
|
|
133
|
+
const result = await write();
|
|
134
|
+
for (const file of files) await clean(ctx, file, true);
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One line to print per entry of `ctx.legacy` (spec R2, §6), with where the copy is. */
|
|
139
|
+
export function legacyLines(ctx) {
|
|
140
|
+
const dir = path.relative(ctx.target, ctx.backupDir).split(path.sep).join('/');
|
|
141
|
+
return ctx.legacy.map(({ file, action }) => (action === 'removed' ? REMOVED(file, `${dir}/${file}`) : EDITED(file)));
|
|
142
|
+
}
|