@esimplicitylabs/katalyst-xspec 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +69 -0
  3. package/bin/katalyst-xspec.cjs +54 -0
  4. package/cli/init.cjs +679 -0
  5. package/cli/stubs.cjs +365 -0
  6. package/cli/upgrade.cjs +1014 -0
  7. package/dist/chunk-ACAXOGKZ.js +1611 -0
  8. package/dist/index.d.ts +881 -0
  9. package/dist/index.js +1091 -0
  10. package/dist/steps/index.d.ts +151 -0
  11. package/dist/steps/index.js +50 -0
  12. package/package.json +80 -0
  13. package/scripts/postinstall.cjs +85 -0
  14. package/skills/katalyst-bdd-architecture/SKILL.md +517 -0
  15. package/skills/katalyst-bdd-architecture/references/adapters.md +310 -0
  16. package/skills/katalyst-bdd-architecture/references/custom-steps.md +360 -0
  17. package/skills/katalyst-bdd-architecture/references/ports.md +256 -0
  18. package/skills/katalyst-bdd-create-test/SKILL.md +366 -0
  19. package/skills/katalyst-bdd-create-test/references/api-patterns.md +371 -0
  20. package/skills/katalyst-bdd-create-test/references/hybrid-patterns.md +420 -0
  21. package/skills/katalyst-bdd-create-test/references/tui-patterns.md +458 -0
  22. package/skills/katalyst-bdd-create-test/references/ui-patterns.md +415 -0
  23. package/skills/katalyst-bdd-quickstart/SKILL.md +292 -0
  24. package/skills/katalyst-bdd-step-reference/SKILL.md +147 -0
  25. package/skills/katalyst-bdd-step-reference/references/api-steps.md +247 -0
  26. package/skills/katalyst-bdd-step-reference/references/shared-steps.md +340 -0
  27. package/skills/katalyst-bdd-step-reference/references/tui-steps.md +483 -0
  28. package/skills/katalyst-bdd-step-reference/references/ui-steps.md +521 -0
  29. package/skills/katalyst-bdd-troubleshooting/SKILL.md +449 -0
@@ -0,0 +1,1014 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { execSync, spawnSync } = require('child_process');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const readline = require('readline');
9
+
10
+ // Published on npmjs.com.
11
+ const PACKAGE_NAME = '@esimplicitylabs/katalyst-xspec';
12
+ // Earlier names; projects on any of these are migrated to PACKAGE_NAME.
13
+ // @esimplicityinc/katalyst-xspec 0.4.0-0.5.0 (GitHub Packages)
14
+ // @esimplicity/stack-tests <= 0.3.0 (npmjs.com)
15
+ const LEGACY_PACKAGE_NAMES = ['@esimplicityinc/katalyst-xspec', '@esimplicity/stack-tests'];
16
+ // The scope mapping 0.4.0-0.5.0 scaffolded into .npmrc; no longer needed.
17
+ const GITHUB_PACKAGES_NPMRC_LINE = '@esimplicityinc:registry=https://npm.pkg.github.com';
18
+
19
+ // Agent skill directories configuration
20
+ const SKILL_AGENTS = {
21
+ 'opencode': '.opencode/skills',
22
+ 'claude-code': '.claude/skills',
23
+ 'cursor': '.cursor/skills',
24
+ 'generic': 'skills',
25
+ };
26
+
27
+ function log(msg) {
28
+ console.log(`[katalyst-xspec upgrade] ${msg}`);
29
+ }
30
+
31
+ function error(msg) {
32
+ console.error(`[katalyst-xspec upgrade] ERROR: ${msg}`);
33
+ }
34
+
35
+ function detectPackageManager(startDir) {
36
+ let dir = startDir;
37
+ while (true) {
38
+ if (fs.existsSync(path.join(dir, 'bun.lockb'))) return 'bun';
39
+ if (fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun';
40
+ if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
41
+ if (fs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
42
+ if (fs.existsSync(path.join(dir, 'package-lock.json'))) return 'npm';
43
+ const parent = path.dirname(dir);
44
+ if (parent === dir) break;
45
+ dir = parent;
46
+ }
47
+ return 'npm';
48
+ }
49
+
50
+ function getInstalledVersion(cwd) {
51
+ for (const [name, legacy] of [[PACKAGE_NAME, false], ...LEGACY_PACKAGE_NAMES.map((n) => [n, true])]) {
52
+ const pkgPath = path.join(cwd, 'node_modules', name, 'package.json');
53
+ if (fs.existsSync(pkgPath)) {
54
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
55
+ return { version: pkg.version, name, legacy };
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ // Matches a quoted legacy name, optionally with a '/<subpath>', but not
62
+ // unrelated packages that merely share the prefix ('@esimplicity/stack-tests-x').
63
+ const LEGACY_SPECIFIER_RE = /(['"])(?:@esimplicityinc\/katalyst-xspec|@esimplicity\/stack-tests)(\/[^'"]*)?\1/g;
64
+
65
+ /**
66
+ * Rewrite legacy import specifiers to the new package name in the files a
67
+ * scaffolded project owns: features/**\/*.{ts,js,mts,cts} and playwright.config.*.
68
+ * Returns the relative paths that (would) change.
69
+ */
70
+ function rewriteLegacyImports(cwd, { dryRun = false } = {}) {
71
+ const candidates = [];
72
+ const walk = (dir) => {
73
+ if (!fs.existsSync(dir)) return;
74
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
75
+ const full = path.join(dir, entry.name);
76
+ if (entry.isDirectory()) {
77
+ if (entry.name !== 'node_modules' && !entry.name.startsWith('.')) walk(full);
78
+ } else if (/\.(ts|js|mts|cts|mjs|cjs)$/.test(entry.name)) {
79
+ candidates.push(full);
80
+ }
81
+ }
82
+ };
83
+ walk(path.join(cwd, 'features'));
84
+ for (const entry of fs.readdirSync(cwd)) {
85
+ if (/^playwright\.config\.(ts|js|mts|cts|mjs|cjs)$/.test(entry)) candidates.push(path.join(cwd, entry));
86
+ }
87
+
88
+ const changed = [];
89
+ for (const file of candidates) {
90
+ const content = fs.readFileSync(file, 'utf8');
91
+ const updated = content.replace(LEGACY_SPECIFIER_RE, (_m, q, sub = '') => `${q}${PACKAGE_NAME}${sub}${q}`);
92
+ if (updated !== content) {
93
+ if (!dryRun) fs.writeFileSync(file, updated);
94
+ changed.push(path.relative(cwd, file));
95
+ }
96
+ }
97
+ return changed;
98
+ }
99
+
100
+ /**
101
+ * Remove the `@esimplicityinc` -> GitHub Packages scope line that 0.4.0-0.5.0
102
+ * scaffolded. Left alone if the file also configures npm.pkg.github.com auth,
103
+ * since then the project likely uses other @esimplicityinc packages.
104
+ * Deletes .npmrc if that line was its only content. Returns true if changed.
105
+ */
106
+ function removeGithubPackagesNpmrc(cwd, { dryRun = false } = {}) {
107
+ const npmrcPath = path.join(cwd, '.npmrc');
108
+ if (!fs.existsSync(npmrcPath)) return false;
109
+ const lines = fs.readFileSync(npmrcPath, 'utf8').split(/\r?\n/);
110
+ if (!lines.some((l) => l.trim() === GITHUB_PACKAGES_NPMRC_LINE)) return false;
111
+ if (lines.some((l) => l.trim().startsWith('//npm.pkg.github.com/'))) return false;
112
+ const kept = lines.filter((l) => l.trim() !== GITHUB_PACKAGES_NPMRC_LINE);
113
+ if (!dryRun) {
114
+ if (kept.every((l) => l.trim() === '')) fs.unlinkSync(npmrcPath);
115
+ else fs.writeFileSync(npmrcPath, kept.join('\n').replace(/\n*$/, '\n'));
116
+ }
117
+ return true;
118
+ }
119
+
120
+ /**
121
+ * Before 0.5.0 the CLIs shipped in a separate package with their own binaries
122
+ * (create-/upgrade-stack-tests, upgrade-katalyst-xspec, generate-step-stubs).
123
+ * Point package.json scripts at the single `katalyst-xspec` command instead.
124
+ * Returns true if package.json changed.
125
+ */
126
+ const LEGACY_SCRIPT_COMMANDS = [
127
+ [/(?:\bnpx\s+)?\b(?:upgrade-stack-tests|upgrade-katalyst-xspec)\b/g, 'katalyst-xspec upgrade'],
128
+ [/(?:\bnpx\s+)?\bgenerate-step-stubs\b/g, 'katalyst-xspec stubs'],
129
+ ];
130
+
131
+ function rewriteLegacyScripts(cwd, { dryRun = false } = {}) {
132
+ const pkgPath = path.join(cwd, 'package.json');
133
+ if (!fs.existsSync(pkgPath)) return false;
134
+ const raw = fs.readFileSync(pkgPath, 'utf8');
135
+ const pkg = JSON.parse(raw);
136
+ if (!pkg.scripts) return false;
137
+
138
+ let changed = false;
139
+ for (const [name, value] of Object.entries(pkg.scripts)) {
140
+ if (typeof value !== 'string') continue;
141
+ const updated = LEGACY_SCRIPT_COMMANDS.reduce((acc, [re, to]) => acc.replace(re, to), value);
142
+ if (updated !== value) {
143
+ pkg.scripts[name] = updated;
144
+ changed = true;
145
+ }
146
+ }
147
+ if (changed && !dryRun) {
148
+ const indent = (raw.match(/^[ \t]+(?=")/m) || [' '])[0];
149
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n');
150
+ }
151
+ return changed;
152
+ }
153
+
154
+ /** Move a project from a legacy package name to PACKAGE_NAME. */
155
+ function migrateLegacyPackage(cwd, pm, legacyName, targetVersion) {
156
+ log(`${legacyName} has been renamed to ${PACKAGE_NAME}. Migrating...`);
157
+ if (removeGithubPackagesNpmrc(cwd)) log(`Removed "${GITHUB_PACKAGES_NPMRC_LINE}" from .npmrc (no longer needed)`);
158
+ for (const file of rewriteLegacyImports(cwd)) log(`Updated imports: ${file}`);
159
+ if (rewriteLegacyScripts(cwd)) log('Updated package.json scripts to use the katalyst-xspec command');
160
+
161
+ const removeCmd = { npm: 'npm uninstall', bun: 'bun remove', pnpm: 'pnpm remove', yarn: 'yarn remove' }[pm] || 'npm uninstall';
162
+ log(`Running: ${removeCmd} ${legacyName}`);
163
+ spawnSync(removeCmd, [legacyName], { stdio: 'inherit', shell: true });
164
+
165
+ // Add as a devDependency, matching what the scaffolder generates.
166
+ const addCmd = { npm: 'npm install -D', bun: 'bun add -d', pnpm: 'pnpm add -D', yarn: 'yarn add -D' }[pm] || 'npm install -D';
167
+ const spec = targetVersion ? `${PACKAGE_NAME}@${targetVersion}` : PACKAGE_NAME;
168
+ log(`Running: ${addCmd} ${spec}`);
169
+ const result = spawnSync(addCmd, [spec], { stdio: 'inherit', shell: true });
170
+ return result.status === 0;
171
+ }
172
+
173
+ function getLatestVersion(packageName) {
174
+ try {
175
+ const result = execSync(`npm view ${packageName} version`, {
176
+ encoding: 'utf8',
177
+ stdio: ['pipe', 'pipe', 'pipe'],
178
+ });
179
+ return result.trim();
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ function runUpgrade(pm, packageName, targetVersion) {
186
+ const versionSpec = targetVersion ? `${packageName}@${targetVersion}` : packageName;
187
+
188
+ const commands = {
189
+ npm: ['npm', 'install', versionSpec],
190
+ bun: ['bun', 'add', versionSpec],
191
+ pnpm: ['pnpm', 'add', versionSpec],
192
+ yarn: ['yarn', 'add', versionSpec],
193
+ };
194
+
195
+ const [cmd, ...args] = commands[pm] || commands.npm;
196
+
197
+ log(`Running: ${cmd} ${args.join(' ')}`);
198
+ const result = spawnSync(cmd, args, { stdio: 'inherit', shell: true });
199
+
200
+ return result.status === 0;
201
+ }
202
+
203
+ function parseArgs(args) {
204
+ const options = {
205
+ check: false,
206
+ version: null,
207
+ help: false,
208
+ updateSkills: false,
209
+ migrate: false,
210
+ dryRun: false,
211
+ backupDir: null,
212
+ interactive: false,
213
+ };
214
+
215
+ for (let i = 0; i < args.length; i++) {
216
+ const arg = args[i];
217
+ if (arg === '--check' || arg === '-c') {
218
+ options.check = true;
219
+ } else if (arg === '--version' || arg === '-v') {
220
+ options.version = args[++i];
221
+ } else if (arg === '--help' || arg === '-h') {
222
+ options.help = true;
223
+ } else if (arg === '--update-skills' || arg === '--skills') {
224
+ options.updateSkills = true;
225
+ } else if (arg === '--migrate') {
226
+ options.migrate = true;
227
+ } else if (arg === '--dry-run') {
228
+ options.dryRun = true;
229
+ } else if (arg === '--backup-dir' && args[i + 1]) {
230
+ options.backupDir = args[++i];
231
+ } else if (arg === '--interactive' || arg === '-i') {
232
+ options.interactive = true;
233
+ }
234
+ }
235
+
236
+ return options;
237
+ }
238
+
239
+ function showHelp() {
240
+ console.log(`
241
+ Usage: npx katalyst-xspec upgrade [options]
242
+
243
+ Upgrade @esimplicitylabs/katalyst-xspec to the latest version.
244
+
245
+ Options:
246
+ -c, --check Check for updates without installing
247
+ -v, --version VER Install a specific version
248
+ --update-skills Update installed Agent Skills to latest version
249
+ --migrate Migrate scaffolding (backup, update, restore custom files)
250
+ --dry-run Show what would change without making changes
251
+ --backup-dir <dir> Custom backup directory (default: /tmp/katalyst-xspec-backup-<timestamp>)
252
+ -i, --interactive Interactive mode with prompts
253
+ -h, --help Show this help message
254
+
255
+ Examples:
256
+ npx katalyst-xspec upgrade # Upgrade to latest
257
+ npx katalyst-xspec upgrade --check # Check for updates only
258
+ npx katalyst-xspec upgrade -v 0.1.1 # Install specific version
259
+ npx katalyst-xspec upgrade --update-skills # Update skills only
260
+ npx katalyst-xspec upgrade --migrate # Full scaffolding migration
261
+ npx katalyst-xspec upgrade --migrate --dry-run # Preview migration changes
262
+ npx katalyst-xspec upgrade -i # Interactive mode
263
+ `);
264
+ }
265
+
266
+ function copyDirSync(src, dest) {
267
+ fs.mkdirSync(dest, { recursive: true });
268
+ const entries = fs.readdirSync(src, { withFileTypes: true });
269
+
270
+ for (const entry of entries) {
271
+ const srcPath = path.join(src, entry.name);
272
+ const destPath = path.join(dest, entry.name);
273
+
274
+ if (entry.isDirectory()) {
275
+ copyDirSync(srcPath, destPath);
276
+ } else {
277
+ fs.copyFileSync(srcPath, destPath);
278
+ }
279
+ }
280
+ }
281
+
282
+ function findInstalledSkillDirs(cwd) {
283
+ const foundDirs = [];
284
+
285
+ for (const [agent, relPath] of Object.entries(SKILL_AGENTS)) {
286
+ const fullPath = path.join(cwd, relPath);
287
+ if (fs.existsSync(fullPath)) {
288
+ // Check if it contains katalyst skills
289
+ const entries = fs.readdirSync(fullPath, { withFileTypes: true });
290
+ const hasKatalystSkills = entries.some(e =>
291
+ e.isDirectory() && e.name.startsWith('katalyst-bdd-')
292
+ );
293
+ if (hasKatalystSkills) {
294
+ foundDirs.push({ agent, path: fullPath, relPath });
295
+ }
296
+ }
297
+ }
298
+
299
+ return foundDirs;
300
+ }
301
+
302
+ function updateSkills(cwd) {
303
+ const skillsSourceDir = path.join(__dirname, '..', 'skills');
304
+
305
+ if (!fs.existsSync(skillsSourceDir)) {
306
+ error('Skills source directory not found in package');
307
+ return false;
308
+ }
309
+
310
+ const installedDirs = findInstalledSkillDirs(cwd);
311
+
312
+ if (installedDirs.length === 0) {
313
+ log('No Katalyst BDD skills found in current directory.');
314
+ log('Skills can be installed with: npx @esimplicitylabs/katalyst-xspec init --with-skills');
315
+ return true;
316
+ }
317
+
318
+ log(`Found skills in ${installedDirs.length} location(s):`);
319
+ installedDirs.forEach(d => log(` - ${d.relPath}`));
320
+
321
+ const skillDirs = fs.readdirSync(skillsSourceDir, { withFileTypes: true })
322
+ .filter(d => d.isDirectory())
323
+ .map(d => d.name);
324
+
325
+ let updated = 0;
326
+ for (const { agent, path: agentSkillsPath, relPath } of installedDirs) {
327
+ log(`Updating skills in ${relPath}...`);
328
+
329
+ for (const skill of skillDirs) {
330
+ const srcSkillDir = path.join(skillsSourceDir, skill);
331
+ const destSkillDir = path.join(agentSkillsPath, skill);
332
+
333
+ // Remove existing and copy fresh
334
+ if (fs.existsSync(destSkillDir)) {
335
+ fs.rmSync(destSkillDir, { recursive: true, force: true });
336
+ }
337
+ copyDirSync(srcSkillDir, destSkillDir);
338
+ updated++;
339
+ }
340
+ }
341
+
342
+ log(`Updated ${updated} skill(s) across ${installedDirs.length} agent(s).`);
343
+ return true;
344
+ }
345
+
346
+ // =============================================================================
347
+ // MIGRATION FUNCTIONALITY
348
+ // =============================================================================
349
+
350
+ /**
351
+ * Find custom step files (any *-steps.ts that's not the main steps.ts)
352
+ */
353
+ function findCustomStepFiles(stepsDir) {
354
+ if (!fs.existsSync(stepsDir)) return [];
355
+
356
+ const files = fs.readdirSync(stepsDir);
357
+ return files.filter(f =>
358
+ f.endsWith('-steps.ts') ||
359
+ f.endsWith('-steps.js') ||
360
+ (f.endsWith('.ts') && f !== 'steps.ts' && f !== 'fixtures.ts')
361
+ );
362
+ }
363
+
364
+ /**
365
+ * Find all feature files
366
+ */
367
+ function findFeatureFiles(featuresDir) {
368
+ const results = [];
369
+
370
+ function walk(dir) {
371
+ if (!fs.existsSync(dir)) return;
372
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
373
+
374
+ for (const entry of entries) {
375
+ const fullPath = path.join(dir, entry.name);
376
+ if (entry.isDirectory() && entry.name !== 'steps' && !entry.name.startsWith('.')) {
377
+ walk(fullPath);
378
+ } else if (entry.isFile() && entry.name.endsWith('.feature')) {
379
+ results.push(fullPath);
380
+ }
381
+ }
382
+ }
383
+
384
+ walk(featuresDir);
385
+ return results;
386
+ }
387
+
388
+ /**
389
+ * Extract custom imports from steps.ts
390
+ */
391
+ function extractCustomImports(stepsContent) {
392
+ const lines = stepsContent.split('\n');
393
+ const customImports = [];
394
+
395
+ for (const line of lines) {
396
+ // Match local imports that aren't fixtures or from the library
397
+ if (line.match(/^import\s+.*from\s+['"]\.\/(?!fixtures)/) ||
398
+ line.match(/^import\s+['"]\.\/(?!fixtures)/)) {
399
+ customImports.push(line);
400
+ }
401
+ }
402
+
403
+ return customImports;
404
+ }
405
+
406
+ /**
407
+ * Extract cleanup rules from fixtures.ts
408
+ */
409
+ function extractCleanupRules(fixturesContent) {
410
+ // Look for DefaultCleanupAdapter configuration
411
+ const match = fixturesContent.match(/new\s+DefaultCleanupAdapter\s*\(\s*\{([^}]+)\}\s*\)/s);
412
+ if (match) {
413
+ return match[1].trim();
414
+ }
415
+ return null;
416
+ }
417
+
418
+ // The project owns these; the template only supplies a fallback.
419
+ const PKG_IDENTITY_KEYS = ['name', 'version', 'description'];
420
+
421
+ // Object-valued keys merged entry-by-entry so project-specific entries survive
422
+ // while the template wins on collision, which is how version bumps land.
423
+ const PKG_MERGED_MAPS = ['scripts', 'dependencies', 'devDependencies', 'engines'];
424
+
425
+ /**
426
+ * Merge package.json - preserve user's custom dependencies/scripts
427
+ */
428
+ function mergePackageJson(existing, template) {
429
+ const existingPkg = JSON.parse(existing);
430
+ const templatePkg = JSON.parse(template);
431
+
432
+ // Existing-first spread. A template-first spread dropped every top-level key
433
+ // the template does not declare, so an upgrade silently deleted `overrides` /
434
+ // `resolutions` security pins, `workspaces` monorepo wiring and
435
+ // `packageManager` from the very project it was meant to remediate.
436
+ const merged = { ...existingPkg, ...templatePkg };
437
+
438
+ PKG_IDENTITY_KEYS.forEach((key) => {
439
+ merged[key] = existingPkg[key] || templatePkg[key];
440
+ });
441
+
442
+ PKG_MERGED_MAPS.forEach((key) => {
443
+ const combined = { ...existingPkg[key], ...templatePkg[key] };
444
+ if (key === 'dependencies' || key === 'devDependencies') LEGACY_PACKAGE_NAMES.forEach((n) => delete combined[n]);
445
+ if (Object.keys(combined).length > 0) {
446
+ merged[key] = combined;
447
+ }
448
+ });
449
+
450
+ return JSON.stringify(merged, null, 2) + '\n';
451
+ }
452
+
453
+ /**
454
+ * Merge steps.ts - add new registrations, preserve custom imports
455
+ */
456
+ function mergeStepsTs(existing, template, customImports) {
457
+ // Start with the template
458
+ let merged = template;
459
+
460
+ // Add custom imports after the library imports
461
+ if (customImports.length > 0) {
462
+ const importEndMatch = merged.match(/from '@esimplicitylabs\/katalyst-xspec\/steps';/);
463
+ if (importEndMatch) {
464
+ const insertPos = merged.indexOf(importEndMatch[0]) + importEndMatch[0].length;
465
+ const customImportBlock = '\n\n// Custom step imports (preserved from migration)\n' +
466
+ customImports.join('\n');
467
+ merged = merged.slice(0, insertPos) + customImportBlock + merged.slice(insertPos);
468
+ }
469
+ }
470
+
471
+ return merged;
472
+ }
473
+
474
+ /**
475
+ * Merge fixtures.ts - preserve cleanup rules
476
+ */
477
+ function mergeFixturesTs(existing, template, cleanupRules) {
478
+ if (!cleanupRules) {
479
+ return template;
480
+ }
481
+
482
+ // Replace DefaultCleanupAdapter() with DefaultCleanupAdapter({ preservedRules })
483
+ return template.replace(
484
+ /new\s+DefaultCleanupAdapter\s*\(\s*\)/,
485
+ `new DefaultCleanupAdapter({\n ${cleanupRules}\n })`
486
+ );
487
+ }
488
+
489
+ /**
490
+ * Get fresh templates (mirrors cli/init.cjs)
491
+ */
492
+ function getTemplates() {
493
+ const pkg = {
494
+ name: 'katalyst-xspec',
495
+ private: true,
496
+ version: '0.1.0',
497
+ type: 'module',
498
+ scripts: {
499
+ gen: 'bddgen',
500
+ test: 'bddgen && playwright test',
501
+ 'gen:stubs': 'katalyst-xspec stubs',
502
+ 'clean:gen': 'rm -rf .features-gen',
503
+ clean: 'rm -rf .features-gen node_modules test-results storage cucumber-report playwright-report'
504
+ },
505
+ devDependencies: {
506
+ '@esimplicitylabs/katalyst-xspec': '^0.6.0',
507
+ '@playwright/test': '^1.49.0',
508
+ 'playwright-bdd': '^9.1.0',
509
+ dotenv: '^16.1.4',
510
+ typescript: '^5.6.3'
511
+ },
512
+ engines: {
513
+ node: '>=20'
514
+ }
515
+ };
516
+
517
+ const fixturesTs = `import {
518
+ createBddTest,
519
+ PlaywrightApiAdapter,
520
+ PlaywrightUiAdapter,
521
+ UniversalAuthAdapter,
522
+ DefaultCleanupAdapter,
523
+ TuiTesterAdapter,
524
+ } from '@esimplicitylabs/katalyst-xspec';
525
+
526
+ export const { test } = createBddTest({
527
+ createApi: ({ apiRequest }) => new PlaywrightApiAdapter(apiRequest),
528
+ createUi: ({ page }) => new PlaywrightUiAdapter(page),
529
+ createAuth: ({ api, ui }) => new UniversalAuthAdapter({ api, ui }),
530
+ createCleanup: () => new DefaultCleanupAdapter(),
531
+ // TUI testing (optional - requires tui-tester and tmux installed)
532
+ // Uncomment and configure for your CLI application:
533
+ // createTui: () => new TuiTesterAdapter({
534
+ // command: ['node', 'dist/cli.js'],
535
+ // size: { cols: 100, rows: 30 },
536
+ // debug: process.env.DEBUG === 'true',
537
+ // }),
538
+ });
539
+ `;
540
+
541
+ const stepsTs = `import { test } from './fixtures.js';
542
+ import {
543
+ registerApiSteps,
544
+ registerUiSteps,
545
+ registerSharedSteps,
546
+ registerHybridSuite,
547
+ registerTuiSteps,
548
+ } from '@esimplicitylabs/katalyst-xspec/steps';
549
+
550
+ registerApiSteps(test);
551
+ registerUiSteps(test);
552
+ registerSharedSteps(test);
553
+ registerHybridSuite(test);
554
+
555
+ // TUI steps (optional - requires tui-tester and tmux installed)
556
+ // Uncomment when you have TUI testing configured:
557
+ // registerTuiSteps(test);
558
+
559
+ export { test };
560
+ `;
561
+
562
+ return {
563
+ 'package.json': JSON.stringify(pkg, null, 2) + '\n',
564
+ 'features/steps/fixtures.ts': fixturesTs,
565
+ 'features/steps/steps.ts': stepsTs,
566
+ };
567
+ }
568
+
569
+ /**
570
+ * Perform migration
571
+ */
572
+ async function migrate(cwd, options) {
573
+ const featuresDir = path.join(cwd, 'features');
574
+ const stepsDir = path.join(cwd, 'features', 'steps');
575
+ const backupDir = options.backupDir ||
576
+ path.join(os.tmpdir(), `katalyst-xspec-backup-${Date.now()}`);
577
+
578
+ const results = {
579
+ backed: [],
580
+ updated: [],
581
+ preserved: [],
582
+ errors: [],
583
+ };
584
+
585
+ log('Starting migration...');
586
+ log(`Backup directory: ${backupDir}`);
587
+ console.log('');
588
+
589
+ // =========================================================================
590
+ // PHASE 1: DETECT CUSTOM FILES
591
+ // =========================================================================
592
+ log('Phase 1: Detecting custom files...');
593
+
594
+ const customStepFiles = findCustomStepFiles(stepsDir);
595
+ const featureFiles = findFeatureFiles(featuresDir);
596
+ const envFile = fs.existsSync(path.join(cwd, '.env')) ? '.env' : null;
597
+ const envExampleFile = fs.existsSync(path.join(cwd, '.env.example')) ? '.env.example' : null;
598
+
599
+ // Read existing files for analysis
600
+ let existingStepsTs = '';
601
+ let existingFixturesTs = '';
602
+ let existingPackageJson = '';
603
+
604
+ if (fs.existsSync(path.join(stepsDir, 'steps.ts'))) {
605
+ existingStepsTs = fs.readFileSync(path.join(stepsDir, 'steps.ts'), 'utf8');
606
+ }
607
+ if (fs.existsSync(path.join(stepsDir, 'fixtures.ts'))) {
608
+ existingFixturesTs = fs.readFileSync(path.join(stepsDir, 'fixtures.ts'), 'utf8');
609
+ }
610
+ if (fs.existsSync(path.join(cwd, 'package.json'))) {
611
+ existingPackageJson = fs.readFileSync(path.join(cwd, 'package.json'), 'utf8');
612
+ }
613
+
614
+ const customImports = extractCustomImports(existingStepsTs);
615
+ const cleanupRules = extractCleanupRules(existingFixturesTs);
616
+
617
+ console.log(' Custom step files found:', customStepFiles.length > 0 ? customStepFiles.join(', ') : 'none');
618
+ console.log(' Feature files found:', featureFiles.length);
619
+ console.log(' Custom imports in steps.ts:', customImports.length);
620
+ console.log(' Custom cleanup rules:', cleanupRules ? 'yes' : 'no');
621
+ console.log(' Environment files:', [envFile, envExampleFile].filter(Boolean).join(', ') || 'none');
622
+ console.log('');
623
+
624
+ // =========================================================================
625
+ // PHASE 2: BACKUP
626
+ // =========================================================================
627
+ log('Phase 2: Backing up custom files...');
628
+
629
+ if (!options.dryRun) {
630
+ fs.mkdirSync(backupDir, { recursive: true });
631
+
632
+ // Backup custom step files
633
+ for (const file of customStepFiles) {
634
+ const src = path.join(stepsDir, file);
635
+ const dest = path.join(backupDir, 'steps', file);
636
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
637
+ fs.copyFileSync(src, dest);
638
+ results.backed.push(`steps/${file}`);
639
+ }
640
+
641
+ // Backup feature files
642
+ for (const featureFile of featureFiles) {
643
+ const relPath = path.relative(cwd, featureFile);
644
+ const dest = path.join(backupDir, relPath);
645
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
646
+ fs.copyFileSync(featureFile, dest);
647
+ results.backed.push(relPath);
648
+ }
649
+
650
+ // Backup env files
651
+ if (envFile) {
652
+ fs.copyFileSync(path.join(cwd, envFile), path.join(backupDir, envFile));
653
+ results.backed.push(envFile);
654
+ }
655
+ if (envExampleFile) {
656
+ fs.copyFileSync(path.join(cwd, envExampleFile), path.join(backupDir, envExampleFile));
657
+ results.backed.push(envExampleFile);
658
+ }
659
+
660
+ // Backup original steps.ts and fixtures.ts for reference
661
+ if (existingStepsTs) {
662
+ fs.writeFileSync(path.join(backupDir, 'steps', 'steps.ts.original'), existingStepsTs);
663
+ }
664
+ if (existingFixturesTs) {
665
+ fs.writeFileSync(path.join(backupDir, 'steps', 'fixtures.ts.original'), existingFixturesTs);
666
+ }
667
+ }
668
+
669
+ console.log(` Backed up ${results.backed.length} files`);
670
+ if (options.dryRun) {
671
+ console.log(' (dry run - no files actually backed up)');
672
+ }
673
+ console.log('');
674
+
675
+ // =========================================================================
676
+ // PHASE 3: MERGE AND UPDATE
677
+ // =========================================================================
678
+ log('Phase 3: Merging configurations...');
679
+
680
+ const templates = getTemplates();
681
+ const filesToUpdate = {};
682
+
683
+ // Merge package.json
684
+ if (existingPackageJson) {
685
+ filesToUpdate['package.json'] = mergePackageJson(existingPackageJson, templates['package.json']);
686
+ console.log(' package.json: merged (preserving custom scripts/dependencies)');
687
+ }
688
+
689
+ // Merge steps.ts
690
+ filesToUpdate['features/steps/steps.ts'] = mergeStepsTs(
691
+ existingStepsTs,
692
+ templates['features/steps/steps.ts'],
693
+ customImports
694
+ );
695
+ console.log(` steps.ts: merged (${customImports.length} custom imports preserved)`);
696
+
697
+ // Merge fixtures.ts
698
+ filesToUpdate['features/steps/fixtures.ts'] = mergeFixturesTs(
699
+ existingFixturesTs,
700
+ templates['features/steps/fixtures.ts'],
701
+ cleanupRules
702
+ );
703
+ console.log(` fixtures.ts: merged (cleanup rules ${cleanupRules ? 'preserved' : 'using defaults'})`);
704
+ console.log('');
705
+
706
+ // =========================================================================
707
+ // PHASE 4: WRITE UPDATED FILES
708
+ // =========================================================================
709
+ log('Phase 4: Writing updated files...');
710
+
711
+ if (!options.dryRun) {
712
+ for (const [relPath, content] of Object.entries(filesToUpdate)) {
713
+ const fullPath = path.join(cwd, relPath);
714
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
715
+ fs.writeFileSync(fullPath, content);
716
+ results.updated.push(relPath);
717
+ }
718
+ }
719
+
720
+ console.log(` Updated ${Object.keys(filesToUpdate).length} files`);
721
+
722
+ // Rename: point legacy imports (custom step files, playwright.config) at the
723
+ // current package, and drop the obsolete GitHub Packages scope line.
724
+ const renamed = rewriteLegacyImports(cwd, { dryRun: options.dryRun });
725
+ if (renamed.length) {
726
+ console.log(` Rewrote legacy imports in ${renamed.length} file(s): ${renamed.join(', ')}`);
727
+ if (!options.dryRun) results.updated.push(...renamed);
728
+ }
729
+ if (removeGithubPackagesNpmrc(cwd, { dryRun: options.dryRun })) {
730
+ console.log(` .npmrc: removed ${GITHUB_PACKAGES_NPMRC_LINE}`);
731
+ if (!options.dryRun) results.updated.push('.npmrc');
732
+ }
733
+ if (rewriteLegacyScripts(cwd, { dryRun: options.dryRun })) {
734
+ console.log(' package.json: scripts now use the katalyst-xspec command');
735
+ }
736
+ if (options.dryRun) {
737
+ console.log(' (dry run - no files actually written)');
738
+ }
739
+ console.log('');
740
+
741
+ // =========================================================================
742
+ // PHASE 5: VERIFY PRESERVED FILES
743
+ // =========================================================================
744
+ log('Phase 5: Verifying preserved files...');
745
+
746
+ // Custom step files should still exist (we didn't touch them)
747
+ for (const file of customStepFiles) {
748
+ const fullPath = path.join(stepsDir, file);
749
+ if (fs.existsSync(fullPath)) {
750
+ results.preserved.push(`steps/${file}`);
751
+ }
752
+ }
753
+
754
+ // Feature files should still exist (we didn't touch them)
755
+ for (const featureFile of featureFiles) {
756
+ const relPath = path.relative(cwd, featureFile);
757
+ if (fs.existsSync(featureFile)) {
758
+ results.preserved.push(relPath);
759
+ }
760
+ }
761
+
762
+ // Env files should still exist (we didn't touch them)
763
+ if (envFile && fs.existsSync(path.join(cwd, envFile))) {
764
+ results.preserved.push(envFile);
765
+ }
766
+
767
+ console.log(` ${results.preserved.length} files preserved`);
768
+ console.log('');
769
+
770
+ // =========================================================================
771
+ // SUMMARY
772
+ // =========================================================================
773
+ console.log('═'.repeat(60));
774
+ console.log('Migration Summary');
775
+ console.log('═'.repeat(60));
776
+ console.log('');
777
+ console.log(`Backup location: ${backupDir}`);
778
+ console.log('');
779
+ console.log('Updated files:');
780
+ for (const file of results.updated) {
781
+ console.log(` ~ ${file}`);
782
+ }
783
+ console.log('');
784
+ console.log('Preserved files:');
785
+ if (results.preserved.length <= 10) {
786
+ for (const file of results.preserved) {
787
+ console.log(` ✓ ${file}`);
788
+ }
789
+ } else {
790
+ for (const file of results.preserved.slice(0, 5)) {
791
+ console.log(` ✓ ${file}`);
792
+ }
793
+ console.log(` ... and ${results.preserved.length - 5} more`);
794
+ }
795
+ console.log('');
796
+
797
+ if (options.dryRun) {
798
+ console.log('This was a dry run. No files were actually modified.');
799
+ console.log('Run without --dry-run to apply changes.');
800
+ } else {
801
+ console.log('Next steps:');
802
+ console.log(' 1) Review changes to package.json, steps.ts, fixtures.ts');
803
+ console.log(' 2) Run: npm install (or your package manager)');
804
+ console.log(' 3) Run: npm run gen');
805
+ console.log(' 4) Run: npm test');
806
+ console.log('');
807
+ console.log(`If something went wrong, restore from: ${backupDir}`);
808
+ }
809
+
810
+ return results;
811
+ }
812
+
813
+ // =============================================================================
814
+ // INTERACTIVE MODE
815
+ // =============================================================================
816
+
817
+ async function promptYesNo(question) {
818
+ const rl = readline.createInterface({
819
+ input: process.stdin,
820
+ output: process.stdout,
821
+ });
822
+ return new Promise((resolve) => {
823
+ rl.question(`${question} (y/n) `, (answer) => {
824
+ rl.close();
825
+ resolve(answer.toLowerCase().startsWith('y'));
826
+ });
827
+ });
828
+ }
829
+
830
+ async function interactiveMode(cwd) {
831
+ console.log('');
832
+ console.log('═'.repeat(60));
833
+ console.log('Katalyst XSpec Interactive Upgrade');
834
+ console.log('═'.repeat(60));
835
+ console.log('');
836
+
837
+ const pm = detectPackageManager(cwd);
838
+ const installed = getInstalledVersion(cwd);
839
+ const latest = installed ? getLatestVersion(installed.name) : null;
840
+
841
+ console.log(`Package manager: ${pm}`);
842
+ if (installed) {
843
+ console.log(`Current version: ${installed.version}`);
844
+ }
845
+ if (latest) {
846
+ console.log(`Latest version: ${latest}`);
847
+ }
848
+ console.log('');
849
+
850
+ // Check for updates
851
+ if (installed && latest && installed.version !== latest) {
852
+ const doUpgrade = await promptYesNo(`Upgrade ${installed.name} from ${installed.version} to ${latest}?`);
853
+ if (doUpgrade) {
854
+ const success = runUpgrade(pm, installed.name, latest);
855
+ if (!success) {
856
+ error('Upgrade failed');
857
+ return;
858
+ }
859
+ log('Package upgraded successfully!');
860
+ console.log('');
861
+ }
862
+ } else if (installed) {
863
+ log('Package is already up to date.');
864
+ console.log('');
865
+ }
866
+
867
+ // Ask about migration
868
+ const doMigrate = await promptYesNo('Would you like to migrate scaffolding files (steps.ts, fixtures.ts, package.json)?');
869
+ if (doMigrate) {
870
+ const dryRunFirst = await promptYesNo('Would you like to preview changes first (dry run)?');
871
+ if (dryRunFirst) {
872
+ await migrate(cwd, { dryRun: true });
873
+ console.log('');
874
+ const proceed = await promptYesNo('Apply these changes?');
875
+ if (proceed) {
876
+ await migrate(cwd, { dryRun: false });
877
+ }
878
+ } else {
879
+ await migrate(cwd, { dryRun: false });
880
+ }
881
+ }
882
+
883
+ // Ask about skills
884
+ const installedSkills = findInstalledSkillDirs(cwd);
885
+ if (installedSkills.length > 0) {
886
+ const updateSkillsChoice = await promptYesNo('Would you like to update AI Agent Skills?');
887
+ if (updateSkillsChoice) {
888
+ updateSkills(cwd);
889
+ }
890
+ }
891
+
892
+ console.log('');
893
+ log('Interactive upgrade complete!');
894
+ }
895
+
896
+ // =============================================================================
897
+ // MAIN
898
+ // =============================================================================
899
+
900
+ async function main() {
901
+ const args = process.argv.slice(2);
902
+ const options = parseArgs(args);
903
+
904
+ if (options.help) {
905
+ showHelp();
906
+ process.exit(0);
907
+ }
908
+
909
+ const cwd = process.cwd();
910
+
911
+ // Interactive mode
912
+ if (options.interactive) {
913
+ await interactiveMode(cwd);
914
+ process.exit(0);
915
+ }
916
+
917
+ // Handle --migrate
918
+ if (options.migrate) {
919
+ log('Starting scaffolding migration...');
920
+ await migrate(cwd, options);
921
+ process.exit(0);
922
+ }
923
+
924
+ // Handle --update-skills separately
925
+ if (options.updateSkills) {
926
+ log('Updating Katalyst BDD Agent Skills...');
927
+ const success = updateSkills(cwd);
928
+ process.exit(success ? 0 : 1);
929
+ }
930
+
931
+ const pm = detectPackageManager(cwd);
932
+
933
+ log(`Detected package manager: ${pm}`);
934
+
935
+ // Check installed version
936
+ const installed = getInstalledVersion(cwd);
937
+ if (!installed) {
938
+ error(`${PACKAGE_NAME} is not installed in this project.`);
939
+ log(`Run: ${pm === 'npm' ? 'npm install' : pm + ' add'} ${PACKAGE_NAME}`);
940
+ process.exit(1);
941
+ }
942
+
943
+ log(`Installed: ${installed.name}@${installed.version}`);
944
+
945
+ if (!installed.legacy && !options.check && rewriteLegacyScripts(cwd)) {
946
+ log('Updated package.json scripts to use the katalyst-xspec command');
947
+ }
948
+
949
+ if (installed.legacy) {
950
+ if (options.check) {
951
+ log(`${installed.name} has been renamed to ${PACKAGE_NAME}. Run without --check to migrate.`);
952
+ process.exit(0);
953
+ }
954
+ const ok = migrateLegacyPackage(cwd, pm, installed.name, options.version);
955
+ if (ok) log(`Migrated to ${PACKAGE_NAME}.`);
956
+ process.exit(ok ? 0 : 1);
957
+ }
958
+
959
+ // Get latest version
960
+ const latest = getLatestVersion(installed.name);
961
+ if (!latest) {
962
+ error(`Could not fetch latest version from registry.`);
963
+ process.exit(1);
964
+ }
965
+
966
+ const targetVersion = options.version || latest;
967
+ log(`Latest available: ${latest}`);
968
+
969
+ if (options.version) {
970
+ log(`Target version: ${options.version}`);
971
+ }
972
+
973
+ // Compare versions
974
+ if (installed.version === targetVersion) {
975
+ log(`Already up to date!`);
976
+ process.exit(0);
977
+ }
978
+
979
+ if (options.check) {
980
+ log(`Update available: ${installed.version} -> ${targetVersion}`);
981
+ log(`Run without --check to upgrade.`);
982
+ process.exit(0);
983
+ }
984
+
985
+ // Perform upgrade
986
+ log(`Upgrading: ${installed.version} -> ${targetVersion}`);
987
+ const success = runUpgrade(pm, installed.name, targetVersion);
988
+
989
+ if (success) {
990
+ log(`Successfully upgraded to ${targetVersion}!`);
991
+ } else {
992
+ error(`Upgrade failed.`);
993
+ process.exit(1);
994
+ }
995
+ }
996
+
997
+ if (require.main === module) {
998
+ main().catch((err) => {
999
+ error(err.message);
1000
+ process.exit(1);
1001
+ });
1002
+ }
1003
+
1004
+ module.exports = {
1005
+ main,
1006
+ PACKAGE_NAME,
1007
+ LEGACY_PACKAGE_NAMES,
1008
+ GITHUB_PACKAGES_NPMRC_LINE,
1009
+ getInstalledVersion,
1010
+ rewriteLegacyImports,
1011
+ rewriteLegacyScripts,
1012
+ removeGithubPackagesNpmrc,
1013
+ mergePackageJson,
1014
+ };