create-byan-agent 2.7.8 → 2.8.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/bin/create-byan-agent-v2.js +119 -184
- package/lib/stt/engine.js +277 -0
- package/lib/stt/parakeet-backend.js +262 -0
- package/lib/stt/whisper-backend.js +171 -0
- package/lib/utils/file-differ.js +110 -0
- package/lib/utils/manifest.js +118 -0
- package/lib/utils/version-compare.js +69 -0
- package/lib/yanstaller/backuper.js +101 -45
- package/lib/yanstaller/index.js +41 -4
- package/lib/yanstaller/updater.js +271 -0
- package/package.json +5 -2
- package/setup-parakeet.js +260 -0
- package/src/webui/api.js +293 -0
- package/src/webui/public/app.js +455 -0
- package/src/webui/public/index.html +192 -0
- package/src/webui/public/style.css +732 -0
- package/src/webui/server.js +215 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parakeet TDT STT Backend
|
|
3
|
+
*
|
|
4
|
+
* Handles installation, configuration and status checks for
|
|
5
|
+
* NVIDIA Parakeet TDT (local NeMo or Docker).
|
|
6
|
+
*
|
|
7
|
+
* @module stt/parakeet-backend
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs-extra');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { execSync } = require('child_process');
|
|
13
|
+
const chalk = require('chalk');
|
|
14
|
+
const ora = require('ora');
|
|
15
|
+
const { detectGPU, commandExists, checkPython } = require('./engine');
|
|
16
|
+
|
|
17
|
+
const PARAKEET_PORT = 8001;
|
|
18
|
+
const DEFAULT_MODEL = 'nvidia/parakeet-tdt-0.6b-v2';
|
|
19
|
+
const MIN_PYTHON_VERSION = '3.8';
|
|
20
|
+
const MIN_VRAM = 4000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Install and configure the Parakeet TDT backend.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} options
|
|
26
|
+
* @param {'local'|'docker'} options.mode
|
|
27
|
+
* @param {string} [options.model]
|
|
28
|
+
* @param {string[]} [options.languages]
|
|
29
|
+
* @param {string} options.projectRoot
|
|
30
|
+
* @returns {Promise<{ success: boolean, model: string, mode: string }>}
|
|
31
|
+
*/
|
|
32
|
+
async function setup(options) {
|
|
33
|
+
const { mode, projectRoot } = options;
|
|
34
|
+
const model = options.model || DEFAULT_MODEL;
|
|
35
|
+
const languages = options.languages || ['fr', 'en'];
|
|
36
|
+
|
|
37
|
+
console.log(chalk.blue('\n Setting up Parakeet TDT backend...'));
|
|
38
|
+
console.log(chalk.gray(` Mode: ${mode} | Model: ${model} | Languages: ${languages.join(', ')}`));
|
|
39
|
+
|
|
40
|
+
const gpu = detectGPU();
|
|
41
|
+
if (!gpu.hasGPU || gpu.vram < MIN_VRAM) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Parakeet TDT requires a GPU with >= ${MIN_VRAM} MB VRAM. `
|
|
44
|
+
+ `Detected: ${gpu.hasGPU ? gpu.vram + ' MB' : 'no GPU'}. Use Whisper instead.`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (mode === 'docker') {
|
|
49
|
+
await setupDocker(projectRoot);
|
|
50
|
+
} else {
|
|
51
|
+
await setupLocal(model);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { success: true, model, mode };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Set up Parakeet via Docker Compose.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} projectRoot
|
|
61
|
+
*/
|
|
62
|
+
async function setupDocker(projectRoot) {
|
|
63
|
+
const spinner = ora('Generating Parakeet Docker Compose...').start();
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
if (!commandExists('docker')) {
|
|
67
|
+
throw new Error('Docker is required for Parakeet Docker mode');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const content = getDockerCompose();
|
|
71
|
+
const composePath = path.join(projectRoot, 'docker-compose.parakeet.yml');
|
|
72
|
+
await fs.writeFile(composePath, content, 'utf-8');
|
|
73
|
+
spinner.succeed(`Parakeet Docker Compose written to ${composePath}`);
|
|
74
|
+
|
|
75
|
+
console.log(chalk.gray(' Start with: docker compose -f docker-compose.parakeet.yml up -d'));
|
|
76
|
+
} catch (err) {
|
|
77
|
+
spinner.fail('Failed to generate Parakeet Docker Compose');
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Set up Parakeet locally via pip + NeMo.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} model
|
|
86
|
+
*/
|
|
87
|
+
async function setupLocal(model) {
|
|
88
|
+
const spinner = ora('Checking Python environment...').start();
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const python = checkPython();
|
|
92
|
+
if (!python.available) {
|
|
93
|
+
throw new Error('Python 3 is required for local Parakeet installation');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!meetsMinVersion(python.version, MIN_PYTHON_VERSION)) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Python >= ${MIN_PYTHON_VERSION} required, found ${python.version}`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
spinner.succeed(`Python ${python.version} detected`);
|
|
103
|
+
|
|
104
|
+
if (!python.hasNemo) {
|
|
105
|
+
const pipSpinner = ora('Installing NVIDIA NeMo ASR toolkit (this may take a while)...').start();
|
|
106
|
+
try {
|
|
107
|
+
execSync('pip3 install "nvidia-nemo[asr]"', {
|
|
108
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
109
|
+
timeout: 600000 // 10 min
|
|
110
|
+
});
|
|
111
|
+
pipSpinner.succeed('NVIDIA NeMo ASR installed');
|
|
112
|
+
} catch (err) {
|
|
113
|
+
pipSpinner.fail('NeMo installation failed');
|
|
114
|
+
console.log(chalk.yellow(' Try manually: pip3 install "nvidia-nemo[asr]"'));
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
console.log(chalk.green(' NeMo ASR already installed'));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Pre-download model so first inference is not slow
|
|
122
|
+
const dlSpinner = ora(`Downloading model ${model}...`).start();
|
|
123
|
+
try {
|
|
124
|
+
execSync(
|
|
125
|
+
`python3 -c "from nemo.collections.asr.models import EncDecRNNTBPEModel; EncDecRNNTBPEModel.from_pretrained('${model}')"`,
|
|
126
|
+
{ stdio: ['pipe', 'pipe', 'pipe'], timeout: 600000 }
|
|
127
|
+
);
|
|
128
|
+
dlSpinner.succeed('Model downloaded');
|
|
129
|
+
} catch (err) {
|
|
130
|
+
dlSpinner.fail('Model download failed');
|
|
131
|
+
console.log(chalk.yellow(` Try manually: python3 -c "from nemo.collections.asr.models import EncDecRNNTBPEModel; EncDecRNNTBPEModel.from_pretrained('${model}')"`));
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
} catch (err) {
|
|
135
|
+
spinner.isSpinning && spinner.fail();
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Generate Docker Compose YAML for the Parakeet NeMo container.
|
|
142
|
+
*
|
|
143
|
+
* @returns {string}
|
|
144
|
+
*/
|
|
145
|
+
function getDockerCompose() {
|
|
146
|
+
return `version: '3.8'
|
|
147
|
+
|
|
148
|
+
services:
|
|
149
|
+
parakeet-server:
|
|
150
|
+
image: nvcr.io/nvidia/nemo:24.07
|
|
151
|
+
ports:
|
|
152
|
+
- "${PARAKEET_PORT}:${PARAKEET_PORT}"
|
|
153
|
+
environment:
|
|
154
|
+
- MODEL_NAME=${DEFAULT_MODEL}
|
|
155
|
+
- NVIDIA_VISIBLE_DEVICES=all
|
|
156
|
+
volumes:
|
|
157
|
+
- parakeet-models:/root/.cache/huggingface
|
|
158
|
+
deploy:
|
|
159
|
+
resources:
|
|
160
|
+
reservations:
|
|
161
|
+
devices:
|
|
162
|
+
- driver: nvidia
|
|
163
|
+
count: 1
|
|
164
|
+
capabilities: [gpu]
|
|
165
|
+
restart: unless-stopped
|
|
166
|
+
healthcheck:
|
|
167
|
+
test: ["CMD", "curl", "-f", "http://localhost:${PARAKEET_PORT}/health"]
|
|
168
|
+
interval: 30s
|
|
169
|
+
timeout: 10s
|
|
170
|
+
retries: 5
|
|
171
|
+
start_period: 120s
|
|
172
|
+
command: >
|
|
173
|
+
python -c "
|
|
174
|
+
from nemo.collections.asr.models import EncDecRNNTBPEModel;
|
|
175
|
+
import json, http.server, io, soundfile as sf, numpy as np;
|
|
176
|
+
model = EncDecRNNTBPEModel.from_pretrained('${DEFAULT_MODEL}');
|
|
177
|
+
|
|
178
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
179
|
+
def do_GET(self):
|
|
180
|
+
if self.path == '/health':
|
|
181
|
+
self.send_response(200);
|
|
182
|
+
self.end_headers();
|
|
183
|
+
self.wfile.write(b'ok');
|
|
184
|
+
else:
|
|
185
|
+
self.send_response(404);
|
|
186
|
+
self.end_headers();
|
|
187
|
+
def do_POST(self):
|
|
188
|
+
length = int(self.headers['Content-Length']);
|
|
189
|
+
audio_bytes = self.rfile.read(length);
|
|
190
|
+
audio, sr = sf.read(io.BytesIO(audio_bytes));
|
|
191
|
+
transcription = model.transcribe([audio]);
|
|
192
|
+
result = json.dumps({'text': transcription[0] if transcription else ''});
|
|
193
|
+
self.send_response(200);
|
|
194
|
+
self.send_header('Content-Type', 'application/json');
|
|
195
|
+
self.end_headers();
|
|
196
|
+
self.wfile.write(result.encode());
|
|
197
|
+
def log_message(self, format, *args):
|
|
198
|
+
pass;
|
|
199
|
+
|
|
200
|
+
httpd = http.server.HTTPServer(('', ${PARAKEET_PORT}), Handler);
|
|
201
|
+
print('Parakeet TDT server listening on port ${PARAKEET_PORT}');
|
|
202
|
+
httpd.serve_forever()
|
|
203
|
+
"
|
|
204
|
+
|
|
205
|
+
volumes:
|
|
206
|
+
parakeet-models:
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Describe system requirements for Parakeet TDT.
|
|
212
|
+
*
|
|
213
|
+
* @returns {{ python: string, vram: string, packages: string[] }}
|
|
214
|
+
*/
|
|
215
|
+
function getRequirements() {
|
|
216
|
+
return {
|
|
217
|
+
python: `>=${MIN_PYTHON_VERSION}`,
|
|
218
|
+
vram: `>=${MIN_VRAM} MB`,
|
|
219
|
+
packages: ['nvidia-nemo[asr]']
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Check whether the Parakeet server is responding.
|
|
225
|
+
*
|
|
226
|
+
* @returns {boolean}
|
|
227
|
+
*/
|
|
228
|
+
function isRunning() {
|
|
229
|
+
try {
|
|
230
|
+
execSync(`curl -sf http://localhost:${PARAKEET_PORT}/health`, {
|
|
231
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
232
|
+
timeout: 3000
|
|
233
|
+
});
|
|
234
|
+
return true;
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Semver-light comparison: is `actual` >= `minimum`?
|
|
242
|
+
* Only compares major.minor.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} actual - e.g. "3.10.12"
|
|
245
|
+
* @param {string} minimum - e.g. "3.8"
|
|
246
|
+
* @returns {boolean}
|
|
247
|
+
*/
|
|
248
|
+
function meetsMinVersion(actual, minimum) {
|
|
249
|
+
const parse = v => v.split('.').map(Number);
|
|
250
|
+
const [aMaj, aMin = 0] = parse(actual);
|
|
251
|
+
const [mMaj, mMin = 0] = parse(minimum);
|
|
252
|
+
return aMaj > mMaj || (aMaj === mMaj && aMin >= mMin);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = {
|
|
256
|
+
setup,
|
|
257
|
+
getDockerCompose,
|
|
258
|
+
getRequirements,
|
|
259
|
+
isRunning,
|
|
260
|
+
PARAKEET_PORT,
|
|
261
|
+
DEFAULT_MODEL
|
|
262
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whisper STT Backend
|
|
3
|
+
*
|
|
4
|
+
* Handles installation, configuration and status checks for
|
|
5
|
+
* faster-whisper-server (Docker or local).
|
|
6
|
+
*
|
|
7
|
+
* @module stt/whisper-backend
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs-extra');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { execSync } = require('child_process');
|
|
13
|
+
const chalk = require('chalk');
|
|
14
|
+
const ora = require('ora');
|
|
15
|
+
const { detectGPU, commandExists, getWhisperModel } = require('./engine');
|
|
16
|
+
|
|
17
|
+
const WHISPER_PORT = 8000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Install and configure the Whisper STT backend.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} options
|
|
23
|
+
* @param {'local'|'docker'} options.mode
|
|
24
|
+
* @param {string} [options.model] - Override model selection
|
|
25
|
+
* @param {string} options.projectRoot - Absolute path to project root
|
|
26
|
+
* @returns {Promise<{ success: boolean, model: string, mode: string }>}
|
|
27
|
+
*/
|
|
28
|
+
async function setup(options) {
|
|
29
|
+
const { mode, projectRoot } = options;
|
|
30
|
+
const gpu = detectGPU();
|
|
31
|
+
const model = options.model || getWhisperModel(gpu.vram);
|
|
32
|
+
|
|
33
|
+
console.log(chalk.blue('\n Setting up Whisper STT backend...'));
|
|
34
|
+
console.log(chalk.gray(` Mode: ${mode} | Model: ${model}`));
|
|
35
|
+
|
|
36
|
+
if (mode === 'docker') {
|
|
37
|
+
await setupDocker(projectRoot, gpu, model);
|
|
38
|
+
} else {
|
|
39
|
+
await setupLocal();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { success: true, model, mode };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Set up Whisper via Docker Compose.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} projectRoot
|
|
49
|
+
* @param {object} gpu
|
|
50
|
+
* @param {string} model
|
|
51
|
+
*/
|
|
52
|
+
async function setupDocker(projectRoot, gpu, model) {
|
|
53
|
+
const spinner = ora('Generating Whisper Docker Compose...').start();
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const content = getDockerCompose({ gpu, model });
|
|
57
|
+
const composePath = path.join(projectRoot, 'docker-compose.turbo-whisper.yml');
|
|
58
|
+
await fs.writeFile(composePath, content, 'utf-8');
|
|
59
|
+
spinner.succeed(`Whisper Docker Compose written to ${composePath}`);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
spinner.fail('Failed to generate Docker Compose');
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Set up Whisper locally (clone repo, create venv, install deps).
|
|
68
|
+
*/
|
|
69
|
+
async function setupLocal() {
|
|
70
|
+
const spinner = ora('Installing Whisper locally...').start();
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const serverDir = path.join(process.env.HOME, 'faster-whisper-server');
|
|
74
|
+
|
|
75
|
+
if (await fs.pathExists(serverDir)) {
|
|
76
|
+
spinner.text = 'Updating faster-whisper-server...';
|
|
77
|
+
execSync('git pull', { cwd: serverDir, stdio: 'pipe' });
|
|
78
|
+
} else {
|
|
79
|
+
spinner.text = 'Cloning faster-whisper-server...';
|
|
80
|
+
execSync(
|
|
81
|
+
`git clone https://github.com/fedirz/faster-whisper-server.git ${serverDir}`,
|
|
82
|
+
{ stdio: 'pipe' }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
spinner.text = 'Installing Python dependencies...';
|
|
87
|
+
execSync('python3 -m venv .venv && .venv/bin/pip install -e .', {
|
|
88
|
+
cwd: serverDir,
|
|
89
|
+
stdio: 'pipe'
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
spinner.succeed('Whisper local installation complete');
|
|
93
|
+
} catch (err) {
|
|
94
|
+
spinner.fail('Whisper local installation failed');
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Generate Docker Compose YAML content for Whisper.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} [opts]
|
|
103
|
+
* @param {object} [opts.gpu] - GPU detection result
|
|
104
|
+
* @param {string} [opts.model] - Whisper model name
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
function getDockerCompose(opts = {}) {
|
|
108
|
+
const gpu = opts.gpu || detectGPU();
|
|
109
|
+
const model = opts.model || getWhisperModel(gpu.vram);
|
|
110
|
+
const modelFullName = `Systran/faster-whisper-${model}`;
|
|
111
|
+
const useGPU = gpu.hasGPU;
|
|
112
|
+
|
|
113
|
+
const gpuBlock = useGPU
|
|
114
|
+
? `
|
|
115
|
+
deploy:
|
|
116
|
+
resources:
|
|
117
|
+
reservations:
|
|
118
|
+
devices:
|
|
119
|
+
- driver: nvidia
|
|
120
|
+
count: 1
|
|
121
|
+
capabilities: [gpu]`
|
|
122
|
+
: '';
|
|
123
|
+
|
|
124
|
+
return `version: '3.8'
|
|
125
|
+
|
|
126
|
+
services:
|
|
127
|
+
whisper-server:
|
|
128
|
+
image: fedirz/faster-whisper-server:latest-${useGPU ? 'cuda' : 'cpu'}
|
|
129
|
+
ports:
|
|
130
|
+
- "${WHISPER_PORT}:${WHISPER_PORT}"
|
|
131
|
+
environment:
|
|
132
|
+
- WHISPER__MODEL=${modelFullName}${gpuBlock}
|
|
133
|
+
restart: unless-stopped
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Select the best Whisper model for the given VRAM.
|
|
139
|
+
* Delegates to the shared engine helper.
|
|
140
|
+
*
|
|
141
|
+
* @param {number} vram - VRAM in MB
|
|
142
|
+
* @returns {string}
|
|
143
|
+
*/
|
|
144
|
+
function getRecommendedModel(vram) {
|
|
145
|
+
return getWhisperModel(vram);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check whether the Whisper server is responding on its port.
|
|
150
|
+
*
|
|
151
|
+
* @returns {boolean}
|
|
152
|
+
*/
|
|
153
|
+
function isRunning() {
|
|
154
|
+
try {
|
|
155
|
+
execSync(`curl -sf http://localhost:${WHISPER_PORT}/health`, {
|
|
156
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
157
|
+
timeout: 3000
|
|
158
|
+
});
|
|
159
|
+
return true;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
setup,
|
|
167
|
+
getDockerCompose,
|
|
168
|
+
getRecommendedModel,
|
|
169
|
+
isRunning,
|
|
170
|
+
WHISPER_PORT
|
|
171
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Differ Utility
|
|
3
|
+
*
|
|
4
|
+
* Compares installed files against template files using SHA-256 hashes.
|
|
5
|
+
*
|
|
6
|
+
* @module utils/file-differ
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const fs = require('fs-extra');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compute SHA-256 hash of a file.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} filePath - Absolute path to the file
|
|
17
|
+
* @returns {Promise<string>} Hex-encoded SHA-256 hash
|
|
18
|
+
*/
|
|
19
|
+
async function hashFile(filePath) {
|
|
20
|
+
const content = await fs.readFile(filePath);
|
|
21
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Recursively collect all file paths relative to a root directory.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} dir - Root directory to scan
|
|
28
|
+
* @param {string} [base] - Base path for recursion (internal)
|
|
29
|
+
* @returns {Promise<string[]>} Array of relative POSIX paths
|
|
30
|
+
*/
|
|
31
|
+
async function collectFiles(dir, base) {
|
|
32
|
+
base = base || dir;
|
|
33
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
34
|
+
let files = [];
|
|
35
|
+
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
const fullPath = path.join(dir, entry.name);
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
const nested = await collectFiles(fullPath, base);
|
|
40
|
+
files = files.concat(nested);
|
|
41
|
+
} else if (entry.isFile()) {
|
|
42
|
+
const rel = path.relative(base, fullPath).split(path.sep).join('/');
|
|
43
|
+
files.push(rel);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {Object} DiffResult
|
|
52
|
+
* @property {string[]} toUpdate - Files that exist in both dirs but differ
|
|
53
|
+
* @property {string[]} toAdd - Files only in template (new files)
|
|
54
|
+
* @property {string[]} toSkip - Files identical in both dirs
|
|
55
|
+
* @property {string[]} toKeep - Files only in installed dir (user-created)
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Compare an installed directory against a template directory.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} installedDir - Path to the installed _byan/ directory
|
|
62
|
+
* @param {string} templateDir - Path to the template _byan/ directory
|
|
63
|
+
* @returns {Promise<DiffResult>}
|
|
64
|
+
*/
|
|
65
|
+
async function diffFiles(installedDir, templateDir) {
|
|
66
|
+
const [installedFiles, templateFiles] = await Promise.all([
|
|
67
|
+
collectFiles(installedDir),
|
|
68
|
+
collectFiles(templateDir)
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const installedSet = new Set(installedFiles);
|
|
72
|
+
const templateSet = new Set(templateFiles);
|
|
73
|
+
|
|
74
|
+
const toUpdate = [];
|
|
75
|
+
const toAdd = [];
|
|
76
|
+
const toSkip = [];
|
|
77
|
+
const toKeep = [];
|
|
78
|
+
|
|
79
|
+
for (const file of templateFiles) {
|
|
80
|
+
if (!installedSet.has(file)) {
|
|
81
|
+
toAdd.push(file);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const [installedHash, templateHash] = await Promise.all([
|
|
86
|
+
hashFile(path.join(installedDir, file)),
|
|
87
|
+
hashFile(path.join(templateDir, file))
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
if (installedHash === templateHash) {
|
|
91
|
+
toSkip.push(file);
|
|
92
|
+
} else {
|
|
93
|
+
toUpdate.push(file);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const file of installedFiles) {
|
|
98
|
+
if (!templateSet.has(file)) {
|
|
99
|
+
toKeep.push(file);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { toUpdate, toAdd, toSkip, toKeep };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
hashFile,
|
|
108
|
+
collectFiles,
|
|
109
|
+
diffFiles
|
|
110
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest Manager
|
|
3
|
+
*
|
|
4
|
+
* Tracks installed BYAN files and their hashes in _byan/.manifest.json.
|
|
5
|
+
* Used by the update system to detect user modifications.
|
|
6
|
+
*
|
|
7
|
+
* @module utils/manifest
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs-extra');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { hashFile, collectFiles } = require('./file-differ');
|
|
13
|
+
|
|
14
|
+
const MANIFEST_FILENAME = '.manifest.json';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} FileEntry
|
|
18
|
+
* @property {string} hash - SHA-256 hex hash at install time
|
|
19
|
+
* @property {boolean} userModified - Whether user changed the file after install
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {Object} Manifest
|
|
24
|
+
* @property {string} version - BYAN version when manifest was created
|
|
25
|
+
* @property {string} createdAt - ISO timestamp
|
|
26
|
+
* @property {string} updatedAt - ISO timestamp
|
|
27
|
+
* @property {Object<string, FileEntry>} files - Map of relative path to file entry
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read the manifest file from a project.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} projectRoot - Project root directory
|
|
34
|
+
* @returns {Promise<Manifest|null>} Manifest object or null if not found
|
|
35
|
+
*/
|
|
36
|
+
async function readManifest(projectRoot) {
|
|
37
|
+
const manifestPath = path.join(projectRoot, '_byan', MANIFEST_FILENAME);
|
|
38
|
+
|
|
39
|
+
if (!await fs.pathExists(manifestPath)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return fs.readJSON(manifestPath);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Write manifest to _byan/.manifest.json.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} projectRoot - Project root directory
|
|
50
|
+
* @param {Manifest} manifest - Manifest object to write
|
|
51
|
+
* @returns {Promise<void>}
|
|
52
|
+
*/
|
|
53
|
+
async function writeManifest(projectRoot, manifest) {
|
|
54
|
+
const manifestPath = path.join(projectRoot, '_byan', MANIFEST_FILENAME);
|
|
55
|
+
manifest.updatedAt = new Date().toISOString();
|
|
56
|
+
await fs.writeJSON(manifestPath, manifest, { spaces: 2 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generate a fresh manifest by scanning the _byan/ directory.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} byanDir - Absolute path to _byan/ directory
|
|
63
|
+
* @param {string} version - BYAN version string
|
|
64
|
+
* @returns {Promise<Manifest>}
|
|
65
|
+
*/
|
|
66
|
+
async function generateManifest(byanDir, version) {
|
|
67
|
+
const files = await collectFiles(byanDir);
|
|
68
|
+
const fileEntries = {};
|
|
69
|
+
const now = new Date().toISOString();
|
|
70
|
+
|
|
71
|
+
for (const relPath of files) {
|
|
72
|
+
if (relPath === MANIFEST_FILENAME) continue;
|
|
73
|
+
const hash = await hashFile(path.join(byanDir, relPath));
|
|
74
|
+
fileEntries[relPath] = { hash, userModified: false };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
version,
|
|
79
|
+
createdAt: now,
|
|
80
|
+
updatedAt: now,
|
|
81
|
+
files: fileEntries
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Detect which files the user has modified since installation.
|
|
87
|
+
* Compares current file hashes against those recorded in the manifest.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} projectRoot - Project root directory
|
|
90
|
+
* @returns {Promise<string[]>} Array of relative paths that were modified by the user
|
|
91
|
+
*/
|
|
92
|
+
async function detectUserModifications(projectRoot) {
|
|
93
|
+
const manifest = await readManifest(projectRoot);
|
|
94
|
+
if (!manifest) return [];
|
|
95
|
+
|
|
96
|
+
const byanDir = path.join(projectRoot, '_byan');
|
|
97
|
+
const modified = [];
|
|
98
|
+
|
|
99
|
+
for (const [relPath, entry] of Object.entries(manifest.files)) {
|
|
100
|
+
const fullPath = path.join(byanDir, relPath);
|
|
101
|
+
if (!await fs.pathExists(fullPath)) continue;
|
|
102
|
+
|
|
103
|
+
const currentHash = await hashFile(fullPath);
|
|
104
|
+
if (currentHash !== entry.hash) {
|
|
105
|
+
modified.push(relPath);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return modified;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = {
|
|
113
|
+
readManifest,
|
|
114
|
+
writeManifest,
|
|
115
|
+
generateManifest,
|
|
116
|
+
detectUserModifications,
|
|
117
|
+
MANIFEST_FILENAME
|
|
118
|
+
};
|