bridge-workspace 0.1.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/src/backend.js ADDED
@@ -0,0 +1,342 @@
1
+ const fs = require('node:fs/promises');
2
+ const http = require('node:http');
3
+ const path = require('node:path');
4
+ const readline = require('node:readline/promises');
5
+ const chokidar = require('chokidar');
6
+ const express = require('express');
7
+ const { WebSocketServer } = require('ws');
8
+
9
+ const {
10
+ createBridgeFilter,
11
+ createFileSnapshot,
12
+ matchesIncludeRules,
13
+ normalizeRelativePath,
14
+ parseIncludeInput,
15
+ parsePort,
16
+ parseRepeatedOption,
17
+ readTextFile,
18
+ } = require('./config');
19
+
20
+ const DEFAULT_HOST_PORT = 3001;
21
+ let proposalConfirmationQueue = Promise.resolve();
22
+
23
+ function assertSafeRelativePath(filePath) {
24
+ const normalized = normalizeRelativePath(filePath);
25
+ if (!normalized || normalized.startsWith('../') || path.isAbsolute(normalized)) {
26
+ throw new Error(`Unsafe backend file path: ${filePath}`);
27
+ }
28
+ return normalized;
29
+ }
30
+
31
+ function createUnifiedDiff(filePath, oldContent, newContent) {
32
+ const oldLines = oldContent.split(/\r?\n/);
33
+ const newLines = newContent.split(/\r?\n/);
34
+ const output = [`--- a/${filePath}`, `+++ b/${filePath}`, `@@ -1,${oldLines.length} +1,${newLines.length} @@`];
35
+
36
+ for (const line of oldLines) {
37
+ output.push(`-${line}`);
38
+ }
39
+ for (const line of newLines) {
40
+ output.push(`+${line}`);
41
+ }
42
+
43
+ return `${output.join('\n')}\n`;
44
+ }
45
+
46
+ async function createChangeProposal(rootDir, filePath, newContent) {
47
+ const safePath = assertSafeRelativePath(filePath);
48
+ const absolutePath = path.join(rootDir, safePath);
49
+ const oldContent = await fs.readFile(absolutePath, 'utf8');
50
+ const patch = createUnifiedDiff(safePath, oldContent, newContent);
51
+ const patchDir = path.join(rootDir, '.bridge', 'patches');
52
+ const patchPath = path.join(patchDir, `${Date.now()}-${safePath.replace(/[\\/]/g, '__')}.patch`);
53
+
54
+ await fs.mkdir(patchDir, { recursive: true });
55
+ await fs.writeFile(patchPath, patch, 'utf8');
56
+
57
+ return {
58
+ filePath: safePath,
59
+ patch,
60
+ patchPath,
61
+ async apply() {
62
+ await fs.writeFile(absolutePath, newContent, 'utf8');
63
+ },
64
+ };
65
+ }
66
+
67
+ async function createFileIndex(rootDir, filter) {
68
+ const initialSnapshot = await createFileSnapshot(rootDir, filter);
69
+ const files = new Map();
70
+
71
+ for (const file of initialSnapshot.files) {
72
+ const absolutePath = path.join(rootDir, file.path);
73
+ const stat = await fs.stat(absolutePath);
74
+ files.set(file.path, {
75
+ path: file.path,
76
+ size: stat.size,
77
+ mtimeMs: stat.mtimeMs,
78
+ content: file.content,
79
+ });
80
+ }
81
+
82
+ function sortedFiles() {
83
+ return Array.from(files.values())
84
+ .sort((left, right) => left.path.localeCompare(right.path))
85
+ .map((file) => ({ path: file.path, size: file.size, mtimeMs: file.mtimeMs }));
86
+ }
87
+
88
+ return {
89
+ snapshot(includeRules = []) {
90
+ return { files: sortedFiles().filter((file) => matchesIncludeRules(file.path, includeRules)) };
91
+ },
92
+ async upsertAbsolutePath(absolutePath) {
93
+ const relativePath = normalizeRelativePath(path.relative(rootDir, absolutePath));
94
+ if (!filter.isAllowed(relativePath)) {
95
+ return null;
96
+ }
97
+ const content = await readTextFile(rootDir, relativePath);
98
+ const stat = await fs.stat(absolutePath);
99
+ files.set(relativePath, { path: relativePath, size: stat.size, mtimeMs: stat.mtimeMs, content });
100
+ return { path: relativePath, content };
101
+ },
102
+ removeAbsolutePath(absolutePath) {
103
+ const relativePath = normalizeRelativePath(path.relative(rootDir, absolutePath));
104
+ files.delete(relativePath);
105
+ return relativePath;
106
+ },
107
+ getContent(filePath) {
108
+ const file = files.get(normalizeRelativePath(filePath));
109
+ return file ? file.content : null;
110
+ },
111
+ hasFile(filePath) {
112
+ return files.has(normalizeRelativePath(filePath));
113
+ },
114
+ };
115
+ }
116
+
117
+ function parseRequestInclude(req) {
118
+ const rawInclude = req.query.include;
119
+ if (Array.isArray(rawInclude)) {
120
+ return parseIncludeInput(rawInclude);
121
+ }
122
+ return parseIncludeInput(rawInclude || '');
123
+ }
124
+
125
+ function printDiffPreview(proposal, output = process.stdout) {
126
+ output.write(`\n\x1b[33m[Bridge] 前端 AI 申请修改 ${proposal.filePath}\x1b[0m\n`);
127
+ output.write(`\x1b[36mPatch: ${proposal.patchPath}\x1b[0m\n`);
128
+ for (const line of proposal.patch.split('\n')) {
129
+ if (line.startsWith('+') && !line.startsWith('+++')) {
130
+ output.write(`\x1b[32m${line}\x1b[0m\n`);
131
+ } else if (line.startsWith('-') && !line.startsWith('---')) {
132
+ output.write(`\x1b[31m${line}\x1b[0m\n`);
133
+ } else {
134
+ output.write(`${line}\n`);
135
+ }
136
+ }
137
+ }
138
+
139
+ async function confirmAndApplyProposal(proposal, options = {}) {
140
+ const output = options.output || process.stdout;
141
+ const rl = options.rl || readline.createInterface({ input: options.input || process.stdin, output });
142
+
143
+ try {
144
+ const answer = await rl.question(`[Bridge] 前端 AI 申请修改 ${proposal.filePath},确认应用此改动吗?(Y/N) `);
145
+ if (answer.trim().toLowerCase() === 'y') {
146
+ await proposal.apply();
147
+ output.write(`[Bridge] 已应用 ${proposal.filePath},请在 IDE/Git 中 Review 未提交改动。\n`);
148
+ return true;
149
+ }
150
+ output.write(`[Bridge] 已拒绝 ${proposal.filePath} 的修改申请。\n`);
151
+ return false;
152
+ } finally {
153
+ if (!options.rl) {
154
+ rl.close();
155
+ }
156
+ }
157
+ }
158
+
159
+ function enqueueProposalConfirmation(proposal, options = {}) {
160
+ const run = () => confirmAndApplyProposal(proposal, options);
161
+ const result = proposalConfirmationQueue.then(run, run);
162
+ proposalConfirmationQueue = result.catch(() => {});
163
+ return result;
164
+ }
165
+
166
+ function broadcastJson(wss, payload) {
167
+ const message = JSON.stringify(payload);
168
+ for (const client of wss.clients) {
169
+ if (client.readyState === client.OPEN) {
170
+ client.send(message);
171
+ }
172
+ }
173
+ }
174
+
175
+ async function createBackendHost(options = {}) {
176
+ const rootDir = path.resolve(options.rootDir || process.cwd());
177
+ const port = Number(options.port || DEFAULT_HOST_PORT);
178
+ const hostInclude = parseIncludeInput(options.include || []);
179
+ const filter = await createBridgeFilter(rootDir, { include: hostInclude });
180
+ const fileIndex = await createFileIndex(rootDir, filter);
181
+ const app = express();
182
+ const server = http.createServer(app);
183
+ const wss = new WebSocketServer({ server });
184
+
185
+ app.use(express.json({ limit: '2mb' }));
186
+
187
+ app.get('/health', (req, res) => {
188
+ res.json({ ok: true, service: 'bridge-host', root: rootDir });
189
+ });
190
+
191
+ app.get('/file-tree', async (req, res, next) => {
192
+ try {
193
+ res.json(fileIndex.snapshot(parseRequestInclude(req)));
194
+ } catch (error) {
195
+ next(error);
196
+ }
197
+ });
198
+
199
+ app.get('/file-content', (req, res) => {
200
+ const filePath = typeof req.query.path === 'string' ? normalizeRelativePath(req.query.path) : '';
201
+ const includeRules = parseRequestInclude(req);
202
+ if (!filePath || !filter.isAllowed(filePath) || !matchesIncludeRules(filePath, includeRules)) {
203
+ res.status(400).json({ error: 'Invalid or disallowed file path' });
204
+ return;
205
+ }
206
+ const content = fileIndex.getContent(filePath);
207
+ if (content === null) {
208
+ res.status(404).json({ error: 'File not found' });
209
+ return;
210
+ }
211
+ res.json({ path: filePath, content });
212
+ });
213
+
214
+ app.post('/api/propose-change', async (req, res, next) => {
215
+ try {
216
+ const { filePath, newContent } = req.body || {};
217
+ if (typeof filePath !== 'string' || typeof newContent !== 'string') {
218
+ res.status(400).json({ error: 'filePath and newContent are required strings' });
219
+ return;
220
+ }
221
+ const relativePath = normalizeRelativePath(filePath);
222
+ const includeRules = parseIncludeInput(req.body.include || []);
223
+ if (!filter.isAllowed(relativePath) || !matchesIncludeRules(relativePath, includeRules) || !fileIndex.hasFile(relativePath)) {
224
+ res.status(400).json({ error: 'File is not allowed by bridge filter' });
225
+ return;
226
+ }
227
+
228
+ const proposal = await createChangeProposal(rootDir, relativePath, newContent);
229
+ printDiffPreview(proposal);
230
+ res.status(202).json({ accepted: true, filePath: proposal.filePath, patchPath: proposal.patchPath });
231
+ enqueueProposalConfirmation(proposal).catch((error) => {
232
+ console.error(`[Bridge] 应用修改失败: ${error.message}`);
233
+ });
234
+ } catch (error) {
235
+ next(error);
236
+ }
237
+ });
238
+
239
+ const watcher = chokidar.watch(rootDir, {
240
+ ignored: (absolutePath) => {
241
+ const relativePath = normalizeRelativePath(path.relative(rootDir, absolutePath));
242
+ if (!relativePath || relativePath === '.') {
243
+ return false;
244
+ }
245
+ return filter.ignores(`${relativePath}/`) || (!absolutePath.endsWith(path.sep) && !filter.isAllowed(relativePath));
246
+ },
247
+ ignoreInitial: true,
248
+ persistent: true,
249
+ });
250
+
251
+ async function emitFileUpdate(absolutePath) {
252
+ const relativePath = normalizeRelativePath(path.relative(rootDir, absolutePath));
253
+ if (!filter.isAllowed(relativePath)) {
254
+ return;
255
+ }
256
+ try {
257
+ const file = await fileIndex.upsertAbsolutePath(absolutePath);
258
+ if (file) {
259
+ broadcastJson(wss, { type: 'file:update', path: file.path, content: file.content });
260
+ }
261
+ } catch (error) {
262
+ if (error.code !== 'ENOENT') {
263
+ broadcastJson(wss, { type: 'file:error', path: relativePath, message: error.message });
264
+ }
265
+ }
266
+ }
267
+
268
+ watcher.on('add', emitFileUpdate);
269
+ watcher.on('change', emitFileUpdate);
270
+ watcher.on('unlink', (absolutePath) => {
271
+ const relativePath = fileIndex.removeAbsolutePath(absolutePath);
272
+ broadcastJson(wss, { type: 'file:delete', path: relativePath });
273
+ });
274
+
275
+ return {
276
+ app,
277
+ server,
278
+ wss,
279
+ watcher,
280
+ fileIndex,
281
+ rootDir,
282
+ port,
283
+ include: hostInclude,
284
+ listen() {
285
+ return new Promise((resolve) => server.listen(port, () => resolve(this)));
286
+ },
287
+ async close() {
288
+ await watcher.close();
289
+ wss.close();
290
+ await new Promise((resolve) => server.close(resolve));
291
+ },
292
+ };
293
+ }
294
+
295
+ function createHostPromptSession(options = {}) {
296
+ const input = options.input || process.stdin;
297
+ const output = options.output || process.stdout;
298
+ return readline.createInterface({ input, output });
299
+ }
300
+
301
+ async function promptHostInclude(options = {}) {
302
+ const output = options.output || process.stdout;
303
+ const rl = options.rl || createHostPromptSession(options);
304
+ try {
305
+ output.write('请输入本次允许前端访问的 include(逗号分隔;为空则不启动)\n');
306
+ output.write('示例:src/main/java/**/dto/**,src/main/java/**/controller/**,README.md,AGENTS.md\n');
307
+ return await rl.question('include > ');
308
+ } finally {
309
+ if (!options.rl) {
310
+ rl.close();
311
+ }
312
+ }
313
+ }
314
+
315
+ async function runBackendHost(argv = process.argv.slice(2)) {
316
+ const cliInclude = parseIncludeInput(parseRepeatedOption(argv, '--include'));
317
+ const include = cliInclude.length ? cliInclude : parseIncludeInput(await promptHostInclude());
318
+ if (!include.length) {
319
+ console.log('[Bridge] 未设置后端 include,host 未启动。');
320
+ return null;
321
+ }
322
+ const host = await createBackendHost({ port: parsePort(argv, 'BRIDGE_PORT', DEFAULT_HOST_PORT), include });
323
+ await host.listen();
324
+ console.log(`Bridge backend host listening on http://0.0.0.0:${host.port}`);
325
+ console.log(`[Bridge] Host include: ${host.include.join(', ')}`);
326
+ return host;
327
+ }
328
+
329
+ module.exports = {
330
+ DEFAULT_HOST_PORT,
331
+ assertSafeRelativePath,
332
+ confirmAndApplyProposal,
333
+ createBackendHost,
334
+ createChangeProposal,
335
+ createFileIndex,
336
+ createHostPromptSession,
337
+ createUnifiedDiff,
338
+ enqueueProposalConfirmation,
339
+ printDiffPreview,
340
+ promptHostInclude,
341
+ runBackendHost,
342
+ };
package/src/config.js ADDED
@@ -0,0 +1,233 @@
1
+ const fs = require('node:fs/promises');
2
+ const path = require('node:path');
3
+ const ignore = require('ignore');
4
+
5
+ const ALLOWED_EXTENSIONS = new Set(['.java', '.ts', '.js', '.go', '.py']);
6
+ const GUIDANCE_FILE_NAMES = new Set(['README.md', 'AGENTS.md', 'CLAUDE.md', 'GEMINI.md', 'SKILL.md']);
7
+ const GUIDANCE_DIRECTORIES = ['.cursor/rules/', '.claude/skills/', 'skills/'];
8
+ const DEFAULT_IGNORE_RULES = [
9
+ 'node_modules/',
10
+ 'dist/',
11
+ 'build/',
12
+ 'target/',
13
+ 'out/',
14
+ 'coverage/',
15
+ '.git/',
16
+ '.idea/',
17
+ '.vscode/',
18
+ '*.log',
19
+ '*.map',
20
+ '*.min.js',
21
+ '*.zip',
22
+ '*.tar',
23
+ '*.gz',
24
+ '*.png',
25
+ '*.jpg',
26
+ '*.jpeg',
27
+ '*.gif',
28
+ '*.webp',
29
+ '*.pdf',
30
+ '*.class',
31
+ '*.jar',
32
+ ];
33
+
34
+ function normalizeRelativePath(filePath) {
35
+ return filePath.split(path.sep).join('/').replace(/\\/g, '/').replace(/^\.\//, '');
36
+ }
37
+
38
+ function isAllowedExtension(filePath) {
39
+ return ALLOWED_EXTENSIONS.has(path.extname(filePath).toLowerCase());
40
+ }
41
+
42
+ function isGuidanceFile(filePath) {
43
+ const relativePath = normalizeRelativePath(filePath);
44
+ const baseName = path.posix.basename(relativePath);
45
+ return GUIDANCE_FILE_NAMES.has(baseName)
46
+ || GUIDANCE_DIRECTORIES.some((directory) => relativePath.startsWith(directory) && relativePath.endsWith('.md'));
47
+ }
48
+
49
+ async function readBridgeIgnore(rootDir) {
50
+ const projectBridgeIgnore = await readIgnoreFile(path.join(rootDir, '.bridgeignore'));
51
+ if (projectBridgeIgnore) {
52
+ return projectBridgeIgnore;
53
+ }
54
+
55
+ const projectGitIgnore = await readIgnoreFile(path.join(rootDir, '.gitignore'));
56
+ if (projectGitIgnore) {
57
+ return projectGitIgnore;
58
+ }
59
+
60
+ const bundledBridgeIgnore = await readIgnoreFile(path.join(__dirname, '..', '.bridgeignore'));
61
+ return bundledBridgeIgnore || DEFAULT_IGNORE_RULES;
62
+ }
63
+
64
+ async function readIgnoreFile(filePath) {
65
+ try {
66
+ const content = await fs.readFile(filePath, 'utf8');
67
+ return content.split(/\r?\n/).filter((line) => line.trim() && !line.trim().startsWith('#'));
68
+ } catch (error) {
69
+ if (error.code === 'ENOENT') {
70
+ return null;
71
+ }
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ async function createBridgeFilter(rootDir) {
77
+ const backendConfig = arguments.length > 1 ? arguments[1] : await loadBackendBridgeConfig(rootDir);
78
+ const bridgeRules = await readBridgeIgnore(rootDir);
79
+ const matcher = ignore().add(DEFAULT_IGNORE_RULES).add(bridgeRules);
80
+ const includeRules = normalizeIncludeRules(backendConfig.include);
81
+
82
+ return {
83
+ isAllowed(filePath) {
84
+ const relativePath = normalizeRelativePath(filePath);
85
+ return (isAllowedExtension(relativePath) || isGuidanceFile(relativePath))
86
+ && matchesIncludeRules(relativePath, includeRules)
87
+ && !matcher.ignores(relativePath);
88
+ },
89
+ ignores(filePath) {
90
+ const relativePath = normalizeRelativePath(filePath);
91
+ return !directoryCouldMatchInclude(relativePath, includeRules) || matcher.ignores(relativePath);
92
+ },
93
+ };
94
+ }
95
+
96
+ async function loadBackendBridgeConfig(rootDir = process.cwd()) {
97
+ try {
98
+ const content = await fs.readFile(path.join(rootDir, 'bridge.config.json'), 'utf8');
99
+ const config = JSON.parse(content);
100
+ return { include: Array.isArray(config.include) ? config.include : [] };
101
+ } catch (error) {
102
+ if (error.code === 'ENOENT') {
103
+ return { include: [] };
104
+ }
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ function normalizeIncludeRules(include = []) {
110
+ return include.map((rule) => normalizeRelativePath(String(rule).trim())).filter(Boolean);
111
+ }
112
+
113
+ function parseIncludeInput(input) {
114
+ if (Array.isArray(input)) {
115
+ return normalizeIncludeRules(input);
116
+ }
117
+ return normalizeIncludeRules(String(input || '').split(','));
118
+ }
119
+
120
+ function matchesIncludeRules(filePath, includeRules) {
121
+ if (!includeRules.length) {
122
+ return true;
123
+ }
124
+ const relativePath = normalizeRelativePath(filePath);
125
+ return includeRules.some((rule) => matchesIncludeRule(relativePath, rule));
126
+ }
127
+
128
+ function matchesAllIncludeScopes(filePath, includeScopes) {
129
+ return includeScopes.every((scope) => matchesIncludeRules(filePath, normalizeIncludeRules(scope)));
130
+ }
131
+
132
+ function matchesIncludeRule(relativePath, rule) {
133
+ if (rule.endsWith('/**')) {
134
+ return relativePath.startsWith(rule.slice(0, -2));
135
+ }
136
+ if (rule.endsWith('/')) {
137
+ return relativePath.startsWith(rule);
138
+ }
139
+ return relativePath === rule || relativePath.startsWith(`${rule}/`);
140
+ }
141
+
142
+ function directoryCouldMatchInclude(directoryPath, includeRules) {
143
+ if (!includeRules.length) {
144
+ return true;
145
+ }
146
+ const normalizedDirectory = normalizeRelativePath(directoryPath).replace(/\/?$/, '/');
147
+ return includeRules.some((rule) => {
148
+ const normalizedRule = rule.endsWith('/**') ? rule.slice(0, -2) : rule;
149
+ const directoryRule = normalizedRule.endsWith('/') ? normalizedRule : `${normalizedRule}/`;
150
+ return directoryRule.startsWith(normalizedDirectory) || normalizedDirectory.startsWith(directoryRule);
151
+ });
152
+ }
153
+
154
+ async function walkAllowedFiles(rootDir, filter, currentDir = rootDir, output = []) {
155
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
156
+
157
+ for (const entry of entries) {
158
+ const absolutePath = path.join(currentDir, entry.name);
159
+ const relativePath = normalizeRelativePath(path.relative(rootDir, absolutePath));
160
+
161
+ if (entry.isDirectory()) {
162
+ if (!filter.ignores(`${relativePath}/`)) {
163
+ await walkAllowedFiles(rootDir, filter, absolutePath, output);
164
+ }
165
+ continue;
166
+ }
167
+
168
+ if (entry.isFile() && filter.isAllowed(relativePath)) {
169
+ output.push(relativePath);
170
+ }
171
+ }
172
+
173
+ return output.sort();
174
+ }
175
+
176
+ async function readTextFile(rootDir, relativePath) {
177
+ return fs.readFile(path.join(rootDir, relativePath), 'utf8');
178
+ }
179
+
180
+ async function createFileSnapshot(rootDir, filter) {
181
+ const files = await walkAllowedFiles(rootDir, filter);
182
+ const snapshotFiles = [];
183
+
184
+ for (const filePath of files) {
185
+ snapshotFiles.push({ path: filePath, content: await readTextFile(rootDir, filePath) });
186
+ }
187
+
188
+ return { files: snapshotFiles };
189
+ }
190
+
191
+ async function loadBridgeConfig(rootDir = process.cwd()) {
192
+ const configPath = path.join(rootDir, 'bridge.config.json');
193
+ const content = await fs.readFile(configPath, 'utf8');
194
+ return JSON.parse(content);
195
+ }
196
+
197
+ function parsePort(argv, envName, fallback) {
198
+ const portIndex = argv.indexOf('--port');
199
+ if (portIndex >= 0 && argv[portIndex + 1]) {
200
+ return Number(argv[portIndex + 1]);
201
+ }
202
+ return Number(process.env[envName] || fallback);
203
+ }
204
+
205
+ function parseRepeatedOption(argv, optionName) {
206
+ const values = [];
207
+ for (let index = 0; index < argv.length; index += 1) {
208
+ if (argv[index] === optionName && argv[index + 1]) {
209
+ values.push(argv[index + 1]);
210
+ index += 1;
211
+ }
212
+ }
213
+ return values;
214
+ }
215
+
216
+ module.exports = {
217
+ ALLOWED_EXTENSIONS,
218
+ DEFAULT_IGNORE_RULES,
219
+ GUIDANCE_DIRECTORIES,
220
+ GUIDANCE_FILE_NAMES,
221
+ createBridgeFilter,
222
+ createFileSnapshot,
223
+ isGuidanceFile,
224
+ loadBackendBridgeConfig,
225
+ loadBridgeConfig,
226
+ matchesAllIncludeScopes,
227
+ matchesIncludeRules,
228
+ normalizeRelativePath,
229
+ parseIncludeInput,
230
+ parsePort,
231
+ parseRepeatedOption,
232
+ readTextFile,
233
+ };