@dzhechkov/skills-bto 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const {
6
+ green, yellow, cyan, bold, dim,
7
+ info, success, warn, error: logError, step,
8
+ copyDirRecursive, copyDirFiltered, fileExists, readJSON,
9
+ ensureDir, getRelativePaths, getRelativePathsFiltered, diffFiles,
10
+ readManifest, writeManifest, getTemplatesDir,
11
+ COMPONENTS, MANIFEST_FILE, getComponentFilter,
12
+ } = require('../utils');
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Main command
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * `@dzhechkov/skills-bto update` — Update an existing BTO skill pack installation.
20
+ *
21
+ * @param {object} options
22
+ * @param {boolean} options.dryRun — Preview without writing anything
23
+ * @param {boolean} options.force — Passed through (unused here)
24
+ * @param {string} options.targetDir — Destination project root
25
+ */
26
+ async function run(options) {
27
+ const { dryRun, targetDir } = options;
28
+ const manifestPath = path.join(targetDir, MANIFEST_FILE);
29
+
30
+ // ── a) Read existing manifest ───────────────────────────────────────────
31
+ if (!fileExists(manifestPath)) {
32
+ logError('BTO skill pack is not installed in this directory.');
33
+ info(`Run ${cyan('@dzhechkov/skills-bto init')} to install.`);
34
+ process.exit(1);
35
+ }
36
+
37
+ const manifest = readManifest(targetDir);
38
+ if (!manifest) {
39
+ logError(`Failed to read ${MANIFEST_FILE} — file may be corrupted.`);
40
+ process.exit(1);
41
+ }
42
+
43
+ const templatesDir = getTemplatesDir();
44
+
45
+ // ── b) Get installed components ─────────────────────────────────────────
46
+ const installedKeys = manifest.components || [];
47
+ if (installedKeys.length === 0) {
48
+ warn('Manifest lists no installed components. Consider running init instead.');
49
+ process.exit(1);
50
+ }
51
+
52
+ info(`Current version: ${dim(manifest.version)}`);
53
+ const pkgPath = path.resolve(__dirname, '../../package.json');
54
+ const pkg = readJSON(pkgPath);
55
+ const newVersion = pkg ? pkg.version : manifest.version;
56
+ info(`Available version: ${bold(newVersion)}`);
57
+ console.log('');
58
+
59
+ // ── c) Diff each component ──────────────────────────────────────────────
60
+ let totalAdded = 0;
61
+ let totalModified = 0;
62
+ let totalUnchanged = 0;
63
+ const filesToCopy = []; // { src, dest, status, relPath }
64
+
65
+ for (const key of installedKeys) {
66
+ const comp = COMPONENTS[key];
67
+ if (!comp) {
68
+ warn(`Unknown component "${key}" in manifest — skipping.`);
69
+ continue;
70
+ }
71
+
72
+ const srcBase = path.join(templatesDir, comp.src);
73
+ const destBase = path.join(targetDir, comp.src);
74
+
75
+ if (!fileExists(srcBase)) {
76
+ warn(`Template source not found for "${key}" — skipping.`);
77
+ continue;
78
+ }
79
+
80
+ const filterFn = getComponentFilter(comp);
81
+
82
+ if (comp.isFile) {
83
+ // Single file comparison
84
+ if (!fileExists(destBase)) {
85
+ filesToCopy.push({ src: srcBase, dest: destBase, status: 'added', relPath: comp.src });
86
+ totalAdded++;
87
+ } else {
88
+ const srcContent = fs.readFileSync(srcBase);
89
+ const destContent = fs.readFileSync(destBase);
90
+ if (srcContent.equals(destContent)) {
91
+ totalUnchanged++;
92
+ } else {
93
+ filesToCopy.push({ src: srcBase, dest: destBase, status: 'modified', relPath: comp.src });
94
+ totalModified++;
95
+ }
96
+ }
97
+ } else {
98
+ // Directory — use diffFiles utility with optional filter
99
+ const diff = diffFiles(srcBase, destBase, filterFn);
100
+
101
+ for (const rel of diff.added) {
102
+ filesToCopy.push({
103
+ src: path.join(srcBase, rel),
104
+ dest: path.join(destBase, rel),
105
+ status: 'added',
106
+ relPath: path.join(comp.src, rel),
107
+ });
108
+ totalAdded++;
109
+ }
110
+
111
+ for (const rel of diff.modified) {
112
+ filesToCopy.push({
113
+ src: path.join(srcBase, rel),
114
+ dest: path.join(destBase, rel),
115
+ status: 'modified',
116
+ relPath: path.join(comp.src, rel),
117
+ });
118
+ totalModified++;
119
+ }
120
+
121
+ totalUnchanged += diff.unchanged.length;
122
+ }
123
+ }
124
+
125
+ // ── Show diff summary ───────────────────────────────────────────────────
126
+ info(bold('Update summary:'));
127
+ console.log(` ${green('+')} ${totalAdded} file(s) to add`);
128
+ console.log(` ${yellow('~')} ${totalModified} file(s) to update`);
129
+ console.log(` ${dim('=')} ${totalUnchanged} file(s) unchanged`);
130
+ console.log('');
131
+
132
+ if (totalAdded === 0 && totalModified === 0) {
133
+ success('Everything is up to date!');
134
+ process.exit(0);
135
+ }
136
+
137
+ if (dryRun) {
138
+ console.log(bold('Files to be changed:'));
139
+ for (const f of filesToCopy) {
140
+ const marker = f.status === 'added' ? green('+ ADD') : yellow('~ MOD');
141
+ console.log(` ${marker} ${f.relPath}`);
142
+ }
143
+ console.log('');
144
+ warn('Dry run — no files were written.');
145
+ process.exit(0);
146
+ }
147
+
148
+ // ── d) Copy updated files ──────────────────────────────────────────────
149
+ for (let i = 0; i < filesToCopy.length; i++) {
150
+ const f = filesToCopy[i];
151
+ const label = f.status === 'added' ? 'Adding' : 'Updating';
152
+ step(i + 1, filesToCopy.length, `${label} ${f.relPath}`);
153
+
154
+ ensureDir(path.dirname(f.dest));
155
+ fs.copyFileSync(f.src, f.dest);
156
+ }
157
+
158
+ // ── e) Update manifest ─────────────────────────────────────────────────
159
+ const allFiles = [];
160
+ for (const key of installedKeys) {
161
+ const comp = COMPONENTS[key];
162
+ if (!comp) continue;
163
+
164
+ const destPath = path.join(targetDir, comp.src);
165
+ const filterFn = getComponentFilter(comp);
166
+
167
+ if (comp.isFile) {
168
+ if (fileExists(destPath)) {
169
+ allFiles.push(comp.src);
170
+ }
171
+ } else if (fileExists(destPath)) {
172
+ const paths = filterFn
173
+ ? getRelativePathsFiltered(destPath, filterFn)
174
+ : getRelativePaths(destPath);
175
+ allFiles.push(...paths.map((rel) => path.join(comp.src, rel)));
176
+ }
177
+ }
178
+
179
+ manifest.version = newVersion;
180
+ manifest.updatedAt = new Date().toISOString();
181
+ manifest.files = allFiles.sort();
182
+
183
+ writeManifest(targetDir, manifest);
184
+ info(`Updated ${MANIFEST_FILE} manifest`);
185
+
186
+ // ── f) Summary ─────────────────────────────────────────────────────────
187
+ console.log('');
188
+ success(bold('Update complete!'));
189
+ console.log(` ${green('+')} ${totalAdded} file(s) added`);
190
+ console.log(` ${yellow('~')} ${totalModified} file(s) updated`);
191
+ console.log(` ${dim('=')} ${totalUnchanged} file(s) unchanged`);
192
+ console.log('');
193
+
194
+ process.exit(0);
195
+ }
196
+
197
+ module.exports = run;
198
+ module.exports.run = run;
package/src/utils.js ADDED
@@ -0,0 +1,398 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // ===========================================================================
7
+ // Colors — ANSI escape codes (zero dependencies)
8
+ // ===========================================================================
9
+
10
+ const supportsColor = process.stdout.isTTY && !process.env.NO_COLOR;
11
+
12
+ function wrap(code, text) {
13
+ if (!supportsColor) return text;
14
+ return `\x1b[${code}m${text}\x1b[0m`;
15
+ }
16
+
17
+ function green(text) { return wrap('32', text); }
18
+ function red(text) { return wrap('31', text); }
19
+ function yellow(text) { return wrap('33', text); }
20
+ function blue(text) { return wrap('34', text); }
21
+ function cyan(text) { return wrap('36', text); }
22
+ function bold(text) { return wrap('1', text); }
23
+ function dim(text) { return wrap('2', text); }
24
+ function gray(text) { return wrap('90', text); }
25
+
26
+ // ===========================================================================
27
+ // Logging
28
+ // ===========================================================================
29
+
30
+ function info(msg) { console.log(blue('[INFO]') + ' ' + msg); }
31
+ function success(msg) { console.log(green('[OK]') + ' ' + msg); }
32
+ function warn(msg) { console.log(yellow('[WARN]') + ' ' + msg); }
33
+ function error(msg) { console.log(red('[ERROR]') + ' ' + msg); }
34
+
35
+ function step(n, total, msg) {
36
+ console.log(cyan(`[${n}/${total}]`) + ' ' + msg);
37
+ }
38
+
39
+ // ===========================================================================
40
+ // File operations — all synchronous, Node.js built-ins only
41
+ // ===========================================================================
42
+
43
+ /**
44
+ * Copy a directory recursively from src to dest, creating dirs as needed.
45
+ * If src is a file, copies the single file.
46
+ */
47
+ function copyDirRecursive(src, dest) {
48
+ const stat = fs.statSync(src);
49
+
50
+ if (stat.isFile()) {
51
+ ensureDir(path.dirname(dest));
52
+ fs.copyFileSync(src, dest);
53
+ return;
54
+ }
55
+
56
+ if (stat.isDirectory()) {
57
+ ensureDir(dest);
58
+ const entries = fs.readdirSync(src);
59
+ for (const entry of entries) {
60
+ const srcEntry = path.join(src, entry);
61
+ const destEntry = path.join(dest, entry);
62
+ copyDirRecursive(srcEntry, destEntry);
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Copy a directory recursively, but only include files matching a filter.
69
+ * The filter function receives the filename (not full path) and returns boolean.
70
+ * Only applies to top-level entries; subdirectories are copied in full.
71
+ */
72
+ function copyDirFiltered(src, dest, filterFn) {
73
+ const stat = fs.statSync(src);
74
+
75
+ if (!stat.isDirectory()) {
76
+ // Single file — apply filter to its basename
77
+ if (filterFn(path.basename(src))) {
78
+ ensureDir(path.dirname(dest));
79
+ fs.copyFileSync(src, dest);
80
+ }
81
+ return;
82
+ }
83
+
84
+ ensureDir(dest);
85
+ const entries = fs.readdirSync(src);
86
+ for (const entry of entries) {
87
+ // Apply filter only to top-level entries
88
+ if (!filterFn(entry)) continue;
89
+
90
+ const srcEntry = path.join(src, entry);
91
+ const destEntry = path.join(dest, entry);
92
+ const entryStat = fs.statSync(srcEntry);
93
+
94
+ if (entryStat.isDirectory()) {
95
+ copyDirRecursive(srcEntry, destEntry);
96
+ } else {
97
+ fs.copyFileSync(srcEntry, destEntry);
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Returns true if the path exists (file or directory).
104
+ */
105
+ function fileExists(filePath) {
106
+ try {
107
+ fs.accessSync(filePath);
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Read and parse a JSON file. Returns null if file not found or invalid.
116
+ */
117
+ function readJSON(filePath) {
118
+ try {
119
+ const raw = fs.readFileSync(filePath, 'utf8');
120
+ return JSON.parse(raw);
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Write an object as JSON with 2-space indentation.
128
+ */
129
+ function writeJSON(filePath, data) {
130
+ ensureDir(path.dirname(filePath));
131
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
132
+ }
133
+
134
+ /**
135
+ * Create a directory (and parents) if it does not exist.
136
+ */
137
+ function ensureDir(dirPath) {
138
+ fs.mkdirSync(dirPath, { recursive: true });
139
+ }
140
+
141
+ /**
142
+ * Return an array of all file paths relative to `dir`, traversed recursively.
143
+ */
144
+ function getRelativePaths(dir) {
145
+ const results = [];
146
+
147
+ function walk(current, rel) {
148
+ const entries = fs.readdirSync(current);
149
+ for (const entry of entries) {
150
+ const full = path.join(current, entry);
151
+ const relPath = rel ? path.join(rel, entry) : entry;
152
+ const stat = fs.statSync(full);
153
+ if (stat.isDirectory()) {
154
+ walk(full, relPath);
155
+ } else {
156
+ results.push(relPath);
157
+ }
158
+ }
159
+ }
160
+
161
+ if (fileExists(dir) && fs.statSync(dir).isDirectory()) {
162
+ walk(dir, '');
163
+ }
164
+
165
+ return results;
166
+ }
167
+
168
+ /**
169
+ * Return file paths relative to `dir`, but only for entries matching the filter.
170
+ * The filter is applied to top-level filenames only.
171
+ * For top-level directories that match, all nested files are included.
172
+ */
173
+ function getRelativePathsFiltered(dir, filterFn) {
174
+ const results = [];
175
+
176
+ if (!fileExists(dir) || !fs.statSync(dir).isDirectory()) {
177
+ return results;
178
+ }
179
+
180
+ const entries = fs.readdirSync(dir);
181
+ for (const entry of entries) {
182
+ if (!filterFn(entry)) continue;
183
+
184
+ const full = path.join(dir, entry);
185
+ const stat = fs.statSync(full);
186
+
187
+ if (stat.isDirectory()) {
188
+ // Include all files inside matched subdirectory
189
+ const nested = getRelativePaths(full);
190
+ for (const rel of nested) {
191
+ results.push(path.join(entry, rel));
192
+ }
193
+ } else {
194
+ results.push(entry);
195
+ }
196
+ }
197
+
198
+ return results;
199
+ }
200
+
201
+ /**
202
+ * Compare files between srcDir and destDir, respecting an optional filter.
203
+ * Returns { added, modified, unchanged, missing }.
204
+ * added: files in src but not in dest
205
+ * modified: files in both but with different content
206
+ * unchanged: files in both with identical content
207
+ * missing: files in dest but not in src (would be removed on clean install)
208
+ */
209
+ function diffFiles(srcDir, destDir, filterFn) {
210
+ const srcFiles = new Set(
211
+ filterFn ? getRelativePathsFiltered(srcDir, filterFn) : getRelativePaths(srcDir)
212
+ );
213
+ const destFiles = new Set(
214
+ filterFn ? getRelativePathsFiltered(destDir, filterFn) : getRelativePaths(destDir)
215
+ );
216
+
217
+ const added = [];
218
+ const modified = [];
219
+ const unchanged = [];
220
+ const missing = [];
221
+
222
+ for (const rel of srcFiles) {
223
+ if (!destFiles.has(rel)) {
224
+ added.push(rel);
225
+ } else {
226
+ const srcContent = fs.readFileSync(path.join(srcDir, rel));
227
+ const destContent = fs.readFileSync(path.join(destDir, rel));
228
+ if (srcContent.equals(destContent)) {
229
+ unchanged.push(rel);
230
+ } else {
231
+ modified.push(rel);
232
+ }
233
+ }
234
+ }
235
+
236
+ for (const rel of destFiles) {
237
+ if (!srcFiles.has(rel)) {
238
+ missing.push(rel);
239
+ }
240
+ }
241
+
242
+ return { added, modified, unchanged, missing };
243
+ }
244
+
245
+ // ===========================================================================
246
+ // Manifest — .skills-bto.json management
247
+ // ===========================================================================
248
+
249
+ const MANIFEST_FILE = '.skills-bto.json';
250
+
251
+ /**
252
+ * Read the manifest from targetDir. Returns null if not found.
253
+ */
254
+ function readManifest(targetDir) {
255
+ return readJSON(path.join(targetDir, MANIFEST_FILE));
256
+ }
257
+
258
+ /**
259
+ * Write the manifest to targetDir.
260
+ */
261
+ function writeManifest(targetDir, data) {
262
+ writeJSON(path.join(targetDir, MANIFEST_FILE), data);
263
+ }
264
+
265
+ /**
266
+ * Create a fresh manifest object.
267
+ */
268
+ function createManifest(version, components, files) {
269
+ return {
270
+ version: version,
271
+ installedAt: new Date().toISOString(),
272
+ components: components,
273
+ files: files,
274
+ };
275
+ }
276
+
277
+ // ===========================================================================
278
+ // Templates path
279
+ // ===========================================================================
280
+
281
+ /**
282
+ * Returns the absolute path to the templates/ directory inside the package.
283
+ */
284
+ function getTemplatesDir() {
285
+ return path.join(__dirname, '..', 'templates');
286
+ }
287
+
288
+ // ===========================================================================
289
+ // Component definitions — BTO-specific
290
+ //
291
+ // BTO components live in shared directories alongside other skill packs.
292
+ // Each component with a `filter` property uses prefix-based filtering:
293
+ // - commands: only files matching `bto*.md`
294
+ // - rules: only files matching `bto-*.md`
295
+ // - agents: only files matching `bto-*.md`
296
+ // - skill: entire `.claude/skills/bto/` directory (no filter needed)
297
+ // ===========================================================================
298
+
299
+ const COMPONENTS = {
300
+ skill: {
301
+ src: '.claude/skills/bto',
302
+ label: 'BTO Skill Pack (3 modules)',
303
+ group: 'core',
304
+ },
305
+ commands: {
306
+ src: '.claude/commands',
307
+ label: 'BTO Commands (4 commands)',
308
+ group: 'core',
309
+ filter: 'bto',
310
+ },
311
+ rules: {
312
+ src: '.claude/rules',
313
+ label: 'BTO Quality Gate Rules',
314
+ group: 'core',
315
+ filter: 'bto',
316
+ },
317
+ agents: {
318
+ src: '.claude/agents',
319
+ label: 'BTO Agent Templates (2)',
320
+ group: 'core',
321
+ filter: 'bto',
322
+ },
323
+ };
324
+
325
+ // ===========================================================================
326
+ // Filter helpers — centralized prefix logic for filtered components
327
+ // ===========================================================================
328
+
329
+ /**
330
+ * Build a filter function for a component.
331
+ * Components with `filter: 'bto'` match files starting with 'bto' (commands)
332
+ * or 'bto-' (rules, agents). Skill component has no filter (entire directory).
333
+ *
334
+ * @param {object} comp — A COMPONENTS entry
335
+ * @returns {Function|null} — Filter function, or null if no filtering needed
336
+ */
337
+ function getComponentFilter(comp) {
338
+ if (!comp.filter) return null;
339
+
340
+ // Determine the prefix pattern based on the component type
341
+ // commands: bto*.md (e.g., bto.md, bto-build.md)
342
+ // rules: bto-*.md (e.g., bto-quality-gate.md)
343
+ // agents: bto-*.md (e.g., bto-builder.md)
344
+ const prefix = comp.filter; // 'bto'
345
+
346
+ return (filename) => {
347
+ return filename.startsWith(prefix);
348
+ };
349
+ }
350
+
351
+ // ===========================================================================
352
+ // Exports
353
+ // ===========================================================================
354
+
355
+ module.exports = {
356
+ // Colors
357
+ green,
358
+ red,
359
+ yellow,
360
+ blue,
361
+ cyan,
362
+ bold,
363
+ dim,
364
+ gray,
365
+
366
+ // Logging
367
+ info,
368
+ success,
369
+ warn,
370
+ error,
371
+ step,
372
+
373
+ // File operations
374
+ copyDirRecursive,
375
+ copyDirFiltered,
376
+ fileExists,
377
+ readJSON,
378
+ writeJSON,
379
+ ensureDir,
380
+ getRelativePaths,
381
+ getRelativePathsFiltered,
382
+ diffFiles,
383
+
384
+ // Manifest
385
+ MANIFEST_FILE,
386
+ readManifest,
387
+ writeManifest,
388
+ createManifest,
389
+
390
+ // Templates
391
+ getTemplatesDir,
392
+
393
+ // Components
394
+ COMPONENTS,
395
+
396
+ // Filters
397
+ getComponentFilter,
398
+ };