forge-workflow 1.3.1 → 1.4.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/.claude/commands/check.md +8 -0
- package/.claude/commands/dev.md +8 -0
- package/.claude/commands/plan.md +8 -0
- package/.claude/commands/rollback.md +724 -0
- package/.claude/settings.json +7 -0
- package/.claude/settings.local.json +8 -1
- package/bin/forge.js +704 -1
- package/docs/WORKFLOW.md +23 -0
- package/package.json +1 -1
package/bin/forge.js
CHANGED
|
@@ -133,7 +133,7 @@ const AGENTS = {
|
|
|
133
133
|
Object.freeze(AGENTS);
|
|
134
134
|
Object.values(AGENTS).forEach(agent => Object.freeze(agent));
|
|
135
135
|
|
|
136
|
-
const COMMANDS = ['status', 'research', 'plan', 'dev', 'check', 'ship', 'review', 'merge', 'verify'];
|
|
136
|
+
const COMMANDS = ['status', 'research', 'plan', 'dev', 'check', 'ship', 'review', 'merge', 'verify', 'rollback'];
|
|
137
137
|
|
|
138
138
|
// Code review tool options
|
|
139
139
|
const CODE_REVIEW_TOOLS = {
|
|
@@ -703,6 +703,353 @@ function detectProjectStatus() {
|
|
|
703
703
|
return status;
|
|
704
704
|
}
|
|
705
705
|
|
|
706
|
+
// Detect project type from package.json
|
|
707
|
+
function detectProjectType() {
|
|
708
|
+
const detection = {
|
|
709
|
+
hasPackageJson: false,
|
|
710
|
+
framework: null,
|
|
711
|
+
frameworkConfidence: 0,
|
|
712
|
+
language: 'javascript',
|
|
713
|
+
languageConfidence: 100,
|
|
714
|
+
projectType: null,
|
|
715
|
+
buildTool: null,
|
|
716
|
+
testFramework: null,
|
|
717
|
+
features: {
|
|
718
|
+
typescript: false,
|
|
719
|
+
monorepo: false,
|
|
720
|
+
docker: false,
|
|
721
|
+
cicd: false
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const pkg = readPackageJson();
|
|
726
|
+
if (!pkg) return detection;
|
|
727
|
+
|
|
728
|
+
detection.hasPackageJson = true;
|
|
729
|
+
|
|
730
|
+
// Detect TypeScript
|
|
731
|
+
if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript) {
|
|
732
|
+
detection.features.typescript = true;
|
|
733
|
+
detection.language = 'typescript';
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Detect monorepo
|
|
737
|
+
if (pkg.workspaces || fs.existsSync(path.join(projectRoot, 'pnpm-workspace.yaml')) || fs.existsSync(path.join(projectRoot, 'lerna.json'))) {
|
|
738
|
+
detection.features.monorepo = true;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Detect Docker
|
|
742
|
+
if (fs.existsSync(path.join(projectRoot, 'Dockerfile')) || fs.existsSync(path.join(projectRoot, 'docker-compose.yml'))) {
|
|
743
|
+
detection.features.docker = true;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Detect CI/CD
|
|
747
|
+
if (fs.existsSync(path.join(projectRoot, '.github/workflows')) ||
|
|
748
|
+
fs.existsSync(path.join(projectRoot, '.gitlab-ci.yml')) ||
|
|
749
|
+
fs.existsSync(path.join(projectRoot, 'azure-pipelines.yml')) ||
|
|
750
|
+
fs.existsSync(path.join(projectRoot, '.circleci/config.yml'))) {
|
|
751
|
+
detection.features.cicd = true;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Framework detection with confidence scoring
|
|
755
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
756
|
+
|
|
757
|
+
// Helper function for test framework detection
|
|
758
|
+
const detectTestFramework = (deps) => {
|
|
759
|
+
if (deps.jest) return 'jest';
|
|
760
|
+
if (deps.vitest) return 'vitest';
|
|
761
|
+
if (deps.mocha) return 'mocha';
|
|
762
|
+
if (deps['@playwright/test']) return 'playwright';
|
|
763
|
+
if (deps.cypress) return 'cypress';
|
|
764
|
+
if (deps.karma) return 'karma';
|
|
765
|
+
return null;
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
// Next.js (highest priority for React projects)
|
|
769
|
+
if (deps.next) {
|
|
770
|
+
detection.framework = 'Next.js';
|
|
771
|
+
detection.frameworkConfidence = 100;
|
|
772
|
+
detection.projectType = 'fullstack';
|
|
773
|
+
detection.buildTool = 'next';
|
|
774
|
+
detection.testFramework = detectTestFramework(deps);
|
|
775
|
+
return detection;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// NestJS (backend framework)
|
|
779
|
+
if (deps['@nestjs/core'] || deps['@nestjs/common']) {
|
|
780
|
+
detection.framework = 'NestJS';
|
|
781
|
+
detection.frameworkConfidence = 100;
|
|
782
|
+
detection.projectType = 'backend';
|
|
783
|
+
detection.buildTool = 'nest';
|
|
784
|
+
detection.testFramework = 'jest';
|
|
785
|
+
return detection;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Angular
|
|
789
|
+
if (deps['@angular/core'] || deps['@angular/cli']) {
|
|
790
|
+
detection.framework = 'Angular';
|
|
791
|
+
detection.frameworkConfidence = 100;
|
|
792
|
+
detection.projectType = 'frontend';
|
|
793
|
+
detection.buildTool = 'ng';
|
|
794
|
+
detection.testFramework = 'karma';
|
|
795
|
+
return detection;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Vue.js
|
|
799
|
+
if (deps.vue) {
|
|
800
|
+
if (deps.nuxt) {
|
|
801
|
+
detection.framework = 'Nuxt';
|
|
802
|
+
detection.frameworkConfidence = 100;
|
|
803
|
+
detection.projectType = 'fullstack';
|
|
804
|
+
detection.buildTool = 'nuxt';
|
|
805
|
+
} else {
|
|
806
|
+
detection.framework = 'Vue.js';
|
|
807
|
+
detection.frameworkConfidence = deps['@vue/cli'] ? 100 : 90;
|
|
808
|
+
detection.projectType = 'frontend';
|
|
809
|
+
detection.buildTool = deps.vite ? 'vite' : deps.webpack ? 'webpack' : 'vue-cli';
|
|
810
|
+
}
|
|
811
|
+
detection.testFramework = detectTestFramework(deps);
|
|
812
|
+
return detection;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// React (without Next.js)
|
|
816
|
+
if (deps.react) {
|
|
817
|
+
detection.framework = 'React';
|
|
818
|
+
detection.frameworkConfidence = 95;
|
|
819
|
+
detection.projectType = 'frontend';
|
|
820
|
+
detection.buildTool = deps.vite ? 'vite' : deps['react-scripts'] ? 'create-react-app' : 'webpack';
|
|
821
|
+
detection.testFramework = detectTestFramework(deps);
|
|
822
|
+
return detection;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Express (backend)
|
|
826
|
+
if (deps.express) {
|
|
827
|
+
detection.framework = 'Express';
|
|
828
|
+
detection.frameworkConfidence = 90;
|
|
829
|
+
detection.projectType = 'backend';
|
|
830
|
+
detection.buildTool = detection.features.typescript ? 'tsc' : 'node';
|
|
831
|
+
detection.testFramework = detectTestFramework(deps);
|
|
832
|
+
return detection;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Fastify (backend)
|
|
836
|
+
if (deps.fastify) {
|
|
837
|
+
detection.framework = 'Fastify';
|
|
838
|
+
detection.frameworkConfidence = 95;
|
|
839
|
+
detection.projectType = 'backend';
|
|
840
|
+
detection.buildTool = detection.features.typescript ? 'tsc' : 'node';
|
|
841
|
+
detection.testFramework = detectTestFramework(deps);
|
|
842
|
+
return detection;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Svelte
|
|
846
|
+
if (deps.svelte) {
|
|
847
|
+
if (deps['@sveltejs/kit']) {
|
|
848
|
+
detection.framework = 'SvelteKit';
|
|
849
|
+
detection.frameworkConfidence = 100;
|
|
850
|
+
detection.projectType = 'fullstack';
|
|
851
|
+
detection.buildTool = 'vite';
|
|
852
|
+
} else {
|
|
853
|
+
detection.framework = 'Svelte';
|
|
854
|
+
detection.frameworkConfidence = 95;
|
|
855
|
+
detection.projectType = 'frontend';
|
|
856
|
+
detection.buildTool = 'vite';
|
|
857
|
+
}
|
|
858
|
+
detection.testFramework = detectTestFramework(deps);
|
|
859
|
+
return detection;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Remix
|
|
863
|
+
if (deps['@remix-run/react']) {
|
|
864
|
+
detection.framework = 'Remix';
|
|
865
|
+
detection.frameworkConfidence = 100;
|
|
866
|
+
detection.projectType = 'fullstack';
|
|
867
|
+
detection.buildTool = 'remix';
|
|
868
|
+
detection.testFramework = detectTestFramework(deps);
|
|
869
|
+
return detection;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Astro
|
|
873
|
+
if (deps.astro) {
|
|
874
|
+
detection.framework = 'Astro';
|
|
875
|
+
detection.frameworkConfidence = 100;
|
|
876
|
+
detection.projectType = 'frontend';
|
|
877
|
+
detection.buildTool = 'astro';
|
|
878
|
+
detection.testFramework = detectTestFramework(deps);
|
|
879
|
+
return detection;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Generic Node.js project
|
|
883
|
+
if (pkg.main || pkg.scripts?.start) {
|
|
884
|
+
detection.framework = 'Node.js';
|
|
885
|
+
detection.frameworkConfidence = 70;
|
|
886
|
+
detection.projectType = 'backend';
|
|
887
|
+
detection.buildTool = detection.features.typescript ? 'tsc' : 'node';
|
|
888
|
+
detection.testFramework = detectTestFramework(deps);
|
|
889
|
+
return detection;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Fallback: generic JavaScript/TypeScript project
|
|
893
|
+
detection.framework = detection.features.typescript ? 'TypeScript' : 'JavaScript';
|
|
894
|
+
detection.frameworkConfidence = 60;
|
|
895
|
+
detection.projectType = 'library';
|
|
896
|
+
detection.buildTool = deps.vite ? 'vite' : deps.webpack ? 'webpack' : 'npm';
|
|
897
|
+
detection.testFramework = detectTestFramework(deps);
|
|
898
|
+
|
|
899
|
+
return detection;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Display project detection results
|
|
903
|
+
function displayProjectType(detection) {
|
|
904
|
+
if (!detection.hasPackageJson) return;
|
|
905
|
+
|
|
906
|
+
console.log('');
|
|
907
|
+
console.log(chalk.cyan(' 📦 Project Detection:'));
|
|
908
|
+
|
|
909
|
+
if (detection.framework) {
|
|
910
|
+
const confidence = detection.frameworkConfidence >= 90 ? '✓' : '~';
|
|
911
|
+
console.log(` Framework: ${chalk.bold(detection.framework)} ${confidence}`);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
if (detection.projectType) {
|
|
915
|
+
console.log(` Type: ${detection.projectType}`);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (detection.buildTool) {
|
|
919
|
+
console.log(` Build: ${detection.buildTool}`);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (detection.testFramework) {
|
|
923
|
+
console.log(` Tests: ${detection.testFramework}`);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const features = [];
|
|
927
|
+
if (detection.features.typescript) features.push('TypeScript');
|
|
928
|
+
if (detection.features.monorepo) features.push('Monorepo');
|
|
929
|
+
if (detection.features.docker) features.push('Docker');
|
|
930
|
+
if (detection.features.cicd) features.push('CI/CD');
|
|
931
|
+
|
|
932
|
+
if (features.length > 0) {
|
|
933
|
+
console.log(` Features: ${features.join(', ')}`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Generate framework-specific tips
|
|
938
|
+
function generateFrameworkTips(detection) {
|
|
939
|
+
const tips = {
|
|
940
|
+
'Next.js': [
|
|
941
|
+
'- Use `npm run dev` for development with hot reload',
|
|
942
|
+
'- Server components are default in App Router',
|
|
943
|
+
'- API routes live in `app/api/` or `pages/api/`'
|
|
944
|
+
],
|
|
945
|
+
'React': [
|
|
946
|
+
'- Prefer functional components with hooks',
|
|
947
|
+
'- Use `React.memo()` for expensive components',
|
|
948
|
+
'- State management: Context API or external library'
|
|
949
|
+
],
|
|
950
|
+
'Vue.js': [
|
|
951
|
+
'- Use Composition API for better TypeScript support',
|
|
952
|
+
'- `<script setup>` is the recommended syntax',
|
|
953
|
+
'- Pinia is the official state management'
|
|
954
|
+
],
|
|
955
|
+
'Angular': [
|
|
956
|
+
'- Use standalone components (Angular 14+)',
|
|
957
|
+
'- Signals for reactive state (Angular 16+)',
|
|
958
|
+
'- RxJS for async operations'
|
|
959
|
+
],
|
|
960
|
+
'NestJS': [
|
|
961
|
+
'- Dependency injection via decorators',
|
|
962
|
+
'- Use `@nestjs/config` for environment variables',
|
|
963
|
+
'- Guards for authentication, Interceptors for logging'
|
|
964
|
+
],
|
|
965
|
+
'Express': [
|
|
966
|
+
'- Use middleware for cross-cutting concerns',
|
|
967
|
+
'- Error handling with next(err)',
|
|
968
|
+
'- Consider Helmet.js for security headers'
|
|
969
|
+
],
|
|
970
|
+
'Fastify': [
|
|
971
|
+
'- Schema-based validation with JSON Schema',
|
|
972
|
+
'- Plugins for reusable functionality',
|
|
973
|
+
'- Async/await by default'
|
|
974
|
+
],
|
|
975
|
+
'SvelteKit': [
|
|
976
|
+
'- File-based routing in `src/routes/`',
|
|
977
|
+
'- Server-side rendering by default',
|
|
978
|
+
'- Form actions for mutations'
|
|
979
|
+
],
|
|
980
|
+
'Nuxt': [
|
|
981
|
+
'- Auto-imports for components and composables',
|
|
982
|
+
'- `useAsyncData()` for data fetching',
|
|
983
|
+
'- Nitro server engine for deployment'
|
|
984
|
+
],
|
|
985
|
+
'Remix': [
|
|
986
|
+
'- Loaders for data fetching',
|
|
987
|
+
'- Actions for mutations',
|
|
988
|
+
'- Progressive enhancement by default'
|
|
989
|
+
],
|
|
990
|
+
'Astro': [
|
|
991
|
+
'- Zero JS by default',
|
|
992
|
+
'- Use client:* directives for interactivity',
|
|
993
|
+
'- Content collections for type-safe content'
|
|
994
|
+
]
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
return tips[detection.framework] || [];
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Update AGENTS.md with project type metadata
|
|
1001
|
+
function updateAgentsMdWithProjectType(detection) {
|
|
1002
|
+
const agentsPath = path.join(projectRoot, 'AGENTS.md');
|
|
1003
|
+
if (!fs.existsSync(agentsPath)) return;
|
|
1004
|
+
|
|
1005
|
+
let content = fs.readFileSync(agentsPath, 'utf-8');
|
|
1006
|
+
|
|
1007
|
+
// Find the project description line (line 3)
|
|
1008
|
+
const lines = content.split('\n');
|
|
1009
|
+
let insertIndex = -1;
|
|
1010
|
+
|
|
1011
|
+
for (let i = 0; i < Math.min(lines.length, 10); i++) {
|
|
1012
|
+
if (lines[i].startsWith('This is a ')) {
|
|
1013
|
+
insertIndex = i + 1;
|
|
1014
|
+
break;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (insertIndex === -1) return;
|
|
1019
|
+
|
|
1020
|
+
// Build metadata section
|
|
1021
|
+
const metadata = [];
|
|
1022
|
+
metadata.push('');
|
|
1023
|
+
if (detection.framework) {
|
|
1024
|
+
metadata.push(`**Framework**: ${detection.framework}`);
|
|
1025
|
+
}
|
|
1026
|
+
if (detection.language && detection.language !== 'javascript') {
|
|
1027
|
+
metadata.push(`**Language**: ${detection.language}`);
|
|
1028
|
+
}
|
|
1029
|
+
if (detection.projectType) {
|
|
1030
|
+
metadata.push(`**Type**: ${detection.projectType}`);
|
|
1031
|
+
}
|
|
1032
|
+
if (detection.buildTool) {
|
|
1033
|
+
metadata.push(`**Build**: \`${detection.buildTool}\``);
|
|
1034
|
+
}
|
|
1035
|
+
if (detection.testFramework) {
|
|
1036
|
+
metadata.push(`**Tests**: ${detection.testFramework}`);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Add framework-specific tips
|
|
1040
|
+
const tips = generateFrameworkTips(detection);
|
|
1041
|
+
if (tips.length > 0) {
|
|
1042
|
+
metadata.push('');
|
|
1043
|
+
metadata.push('**Framework conventions**:');
|
|
1044
|
+
metadata.push(...tips);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Insert metadata
|
|
1048
|
+
lines.splice(insertIndex, 0, ...metadata);
|
|
1049
|
+
|
|
1050
|
+
fs.writeFileSync(agentsPath, lines.join('\n'), 'utf-8');
|
|
1051
|
+
}
|
|
1052
|
+
|
|
706
1053
|
// Smart file selection with context warnings
|
|
707
1054
|
async function handleInstructionFiles(rl, question, selectedAgents, projectStatus) {
|
|
708
1055
|
const hasClaude = selectedAgents.some(a => a.key === 'claude');
|
|
@@ -1152,6 +1499,13 @@ function minimalInstall() {
|
|
|
1152
1499
|
const agentsSrc = path.join(packageDir, 'AGENTS.md');
|
|
1153
1500
|
if (copyFile(agentsSrc, 'AGENTS.md')) {
|
|
1154
1501
|
console.log(' Created: AGENTS.md (universal standard)');
|
|
1502
|
+
|
|
1503
|
+
// Detect project type and update AGENTS.md
|
|
1504
|
+
const detection = detectProjectType();
|
|
1505
|
+
if (detection.hasPackageJson) {
|
|
1506
|
+
updateAgentsMdWithProjectType(detection);
|
|
1507
|
+
displayProjectType(detection);
|
|
1508
|
+
}
|
|
1155
1509
|
}
|
|
1156
1510
|
}
|
|
1157
1511
|
|
|
@@ -1533,6 +1887,13 @@ async function interactiveSetup() {
|
|
|
1533
1887
|
// New file
|
|
1534
1888
|
if (copyFile(agentsSrc, 'AGENTS.md')) {
|
|
1535
1889
|
console.log(' Created: AGENTS.md (universal standard)');
|
|
1890
|
+
|
|
1891
|
+
// Detect project type and update AGENTS.md
|
|
1892
|
+
const detection = detectProjectType();
|
|
1893
|
+
if (detection.hasPackageJson) {
|
|
1894
|
+
updateAgentsMdWithProjectType(detection);
|
|
1895
|
+
displayProjectType(detection);
|
|
1896
|
+
}
|
|
1536
1897
|
}
|
|
1537
1898
|
}
|
|
1538
1899
|
}
|
|
@@ -2014,6 +2375,13 @@ async function interactiveSetupWithFlags(flags) {
|
|
|
2014
2375
|
// New file
|
|
2015
2376
|
if (copyFile(agentsSrc, 'AGENTS.md')) {
|
|
2016
2377
|
console.log(' Created: AGENTS.md (universal standard)');
|
|
2378
|
+
|
|
2379
|
+
// Detect project type and update AGENTS.md
|
|
2380
|
+
const detection = detectProjectType();
|
|
2381
|
+
if (detection.hasPackageJson) {
|
|
2382
|
+
updateAgentsMdWithProjectType(detection);
|
|
2383
|
+
displayProjectType(detection);
|
|
2384
|
+
}
|
|
2017
2385
|
}
|
|
2018
2386
|
}
|
|
2019
2387
|
}
|
|
@@ -2269,10 +2637,345 @@ async function main() {
|
|
|
2269
2637
|
|
|
2270
2638
|
// Interactive setup (skip-external still applies)
|
|
2271
2639
|
await interactiveSetupWithFlags(flags);
|
|
2640
|
+
} else if (command === 'rollback') {
|
|
2641
|
+
// Execute rollback menu
|
|
2642
|
+
await showRollbackMenu();
|
|
2272
2643
|
} else {
|
|
2273
2644
|
// Default: minimal install (postinstall behavior)
|
|
2274
2645
|
minimalInstall();
|
|
2275
2646
|
}
|
|
2276
2647
|
}
|
|
2277
2648
|
|
|
2649
|
+
// ============================================================================
|
|
2650
|
+
// ROLLBACK SYSTEM - TDD Validated
|
|
2651
|
+
// ============================================================================
|
|
2652
|
+
// Security: All inputs validated before use in git commands
|
|
2653
|
+
// See test/rollback-validation.test.js for validation test coverage
|
|
2654
|
+
|
|
2655
|
+
// Validate rollback inputs (security-critical)
|
|
2656
|
+
function validateRollbackInput(method, target) {
|
|
2657
|
+
const validMethods = ['commit', 'pr', 'partial', 'branch'];
|
|
2658
|
+
if (!validMethods.includes(method)) {
|
|
2659
|
+
return { valid: false, error: 'Invalid method' };
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
// Validate commit hash (git allows 4-40 char abbreviations)
|
|
2663
|
+
if (method === 'commit' || method === 'pr') {
|
|
2664
|
+
if (target !== 'HEAD' && !/^[0-9a-f]{4,40}$/i.test(target)) {
|
|
2665
|
+
return { valid: false, error: 'Invalid commit hash format' };
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
// Validate file paths
|
|
2670
|
+
if (method === 'partial') {
|
|
2671
|
+
const files = target.split(',').map(f => f.trim());
|
|
2672
|
+
for (const file of files) {
|
|
2673
|
+
// Reject shell metacharacters
|
|
2674
|
+
if (/[;|&$`()<>\r\n]/.test(file)) {
|
|
2675
|
+
return { valid: false, error: `Invalid characters in path: ${file}` };
|
|
2676
|
+
}
|
|
2677
|
+
// Reject URL-encoded path traversal attempts
|
|
2678
|
+
if (/%2[eE]|%2[fF]|%5[cC]/.test(file)) {
|
|
2679
|
+
return { valid: false, error: `URL-encoded characters not allowed: ${file}` };
|
|
2680
|
+
}
|
|
2681
|
+
// Reject non-ASCII/unicode characters
|
|
2682
|
+
if (!/^[\x20-\x7E]+$/.test(file)) {
|
|
2683
|
+
return { valid: false, error: `Only ASCII characters allowed in path: ${file}` };
|
|
2684
|
+
}
|
|
2685
|
+
// Prevent path traversal
|
|
2686
|
+
const resolved = path.resolve(projectRoot, file);
|
|
2687
|
+
if (!resolved.startsWith(projectRoot)) {
|
|
2688
|
+
return { valid: false, error: `Path outside project: ${file}` };
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
// Validate branch range
|
|
2694
|
+
if (method === 'branch') {
|
|
2695
|
+
if (!target.includes('..')) {
|
|
2696
|
+
return { valid: false, error: 'Branch range must use format: start..end' };
|
|
2697
|
+
}
|
|
2698
|
+
const [start, end] = target.split('..');
|
|
2699
|
+
if (!/^[0-9a-f]{4,40}$/i.test(start) || !/^[0-9a-f]{4,40}$/i.test(end)) {
|
|
2700
|
+
return { valid: false, error: 'Invalid commit hashes in range' };
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
return { valid: true };
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
// Extract USER sections before rollback
|
|
2708
|
+
function extractUserSections(filePath) {
|
|
2709
|
+
if (!fs.existsSync(filePath)) return {};
|
|
2710
|
+
|
|
2711
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
2712
|
+
const sections = {};
|
|
2713
|
+
|
|
2714
|
+
// Extract USER sections
|
|
2715
|
+
const userRegex = /<!-- USER:START -->([\s\S]*?)<!-- USER:END -->/g;
|
|
2716
|
+
let match;
|
|
2717
|
+
let index = 0;
|
|
2718
|
+
|
|
2719
|
+
while ((match = userRegex.exec(content)) !== null) {
|
|
2720
|
+
sections[`user_${index}`] = match[1];
|
|
2721
|
+
index++;
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
// Extract custom commands
|
|
2725
|
+
const customCommandsDir = path.join(path.dirname(filePath), '.claude', 'commands', 'custom');
|
|
2726
|
+
if (fs.existsSync(customCommandsDir)) {
|
|
2727
|
+
sections.customCommands = fs.readdirSync(customCommandsDir)
|
|
2728
|
+
.filter(f => f.endsWith('.md'))
|
|
2729
|
+
.map(f => ({
|
|
2730
|
+
name: f,
|
|
2731
|
+
content: fs.readFileSync(path.join(customCommandsDir, f), 'utf-8')
|
|
2732
|
+
}));
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
return sections;
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
// Restore USER sections after rollback
|
|
2739
|
+
function preserveUserSections(filePath, savedSections) {
|
|
2740
|
+
if (!fs.existsSync(filePath) || Object.keys(savedSections).length === 0) {
|
|
2741
|
+
return;
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
let content = fs.readFileSync(filePath, 'utf-8');
|
|
2745
|
+
|
|
2746
|
+
// Restore USER sections
|
|
2747
|
+
let index = 0;
|
|
2748
|
+
content = content.replace(
|
|
2749
|
+
/<!-- USER:START -->[\s\S]*?<!-- USER:END -->/g,
|
|
2750
|
+
() => {
|
|
2751
|
+
const section = savedSections[`user_${index}`];
|
|
2752
|
+
index++;
|
|
2753
|
+
return section ? `<!-- USER:START -->${section}<!-- USER:END -->` : '';
|
|
2754
|
+
}
|
|
2755
|
+
);
|
|
2756
|
+
|
|
2757
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
2758
|
+
|
|
2759
|
+
// Restore custom commands
|
|
2760
|
+
if (savedSections.customCommands) {
|
|
2761
|
+
const customCommandsDir = path.join(path.dirname(filePath), '.claude', 'commands', 'custom');
|
|
2762
|
+
if (!fs.existsSync(customCommandsDir)) {
|
|
2763
|
+
fs.mkdirSync(customCommandsDir, { recursive: true });
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
savedSections.customCommands.forEach(cmd => {
|
|
2767
|
+
fs.writeFileSync(
|
|
2768
|
+
path.join(customCommandsDir, cmd.name),
|
|
2769
|
+
cmd.content,
|
|
2770
|
+
'utf-8'
|
|
2771
|
+
);
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
// Perform rollback operation
|
|
2777
|
+
async function performRollback(method, target, dryRun = false) {
|
|
2778
|
+
console.log('');
|
|
2779
|
+
console.log(chalk.cyan(` 🔄 Rollback: ${method}`));
|
|
2780
|
+
console.log(` Target: ${target}`);
|
|
2781
|
+
if (dryRun) {
|
|
2782
|
+
console.log(chalk.yellow(' Mode: DRY RUN (preview only)'));
|
|
2783
|
+
}
|
|
2784
|
+
console.log('');
|
|
2785
|
+
|
|
2786
|
+
// Validate inputs BEFORE any git operations
|
|
2787
|
+
const validation = validateRollbackInput(method, target);
|
|
2788
|
+
if (!validation.valid) {
|
|
2789
|
+
console.log(chalk.red(` ❌ ${validation.error}`));
|
|
2790
|
+
return false;
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
// Check for clean working directory
|
|
2794
|
+
try {
|
|
2795
|
+
const { execSync } = require('child_process');
|
|
2796
|
+
const status = execSync('git status --porcelain', { encoding: 'utf-8' });
|
|
2797
|
+
if (status.trim() !== '') {
|
|
2798
|
+
console.log(chalk.red(' ❌ Working directory has uncommitted changes'));
|
|
2799
|
+
console.log(' Commit or stash changes before rollback');
|
|
2800
|
+
return false;
|
|
2801
|
+
}
|
|
2802
|
+
} catch (err) {
|
|
2803
|
+
console.log(chalk.red(' ❌ Git error:'), err.message);
|
|
2804
|
+
return false;
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
// Extract USER sections before rollback
|
|
2808
|
+
const agentsPath = path.join(projectRoot, 'AGENTS.md');
|
|
2809
|
+
const savedSections = extractUserSections(agentsPath);
|
|
2810
|
+
|
|
2811
|
+
if (!dryRun) {
|
|
2812
|
+
console.log(' 📦 Backing up user content...');
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
try {
|
|
2816
|
+
const { execSync } = require('child_process');
|
|
2817
|
+
|
|
2818
|
+
if (method === 'commit') {
|
|
2819
|
+
if (dryRun) {
|
|
2820
|
+
console.log(` Would revert: ${target}`);
|
|
2821
|
+
const files = execSync(`git diff-tree --no-commit-id --name-only -r ${target}`, { encoding: 'utf-8' });
|
|
2822
|
+
console.log(' Affected files:');
|
|
2823
|
+
files.trim().split('\n').forEach(f => console.log(` - ${f}`));
|
|
2824
|
+
} else {
|
|
2825
|
+
execSync(`git revert --no-edit ${target}`, { stdio: 'inherit' });
|
|
2826
|
+
}
|
|
2827
|
+
} else if (method === 'pr') {
|
|
2828
|
+
if (dryRun) {
|
|
2829
|
+
console.log(` Would revert merge: ${target}`);
|
|
2830
|
+
const files = execSync(`git diff-tree --no-commit-id --name-only -r ${target}`, { encoding: 'utf-8' });
|
|
2831
|
+
console.log(' Affected files:');
|
|
2832
|
+
files.trim().split('\n').forEach(f => console.log(` - ${f}`));
|
|
2833
|
+
} else {
|
|
2834
|
+
execSync(`git revert -m 1 --no-edit ${target}`, { stdio: 'inherit' });
|
|
2835
|
+
|
|
2836
|
+
// Update Beads issue if linked
|
|
2837
|
+
const commitMsg = execSync(`git log -1 --format=%B ${target}`, { encoding: 'utf-8' });
|
|
2838
|
+
const issueMatch = commitMsg.match(/#(\d+)/);
|
|
2839
|
+
if (issueMatch) {
|
|
2840
|
+
try {
|
|
2841
|
+
execSync(`bd update ${issueMatch[1]} --status reverted --comment "PR reverted"`, { stdio: 'inherit' });
|
|
2842
|
+
console.log(` Updated Beads issue #${issueMatch[1]} to 'reverted'`);
|
|
2843
|
+
} catch {
|
|
2844
|
+
// Beads not installed - silently continue
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
} else if (method === 'partial') {
|
|
2849
|
+
const files = target.split(',').map(f => f.trim());
|
|
2850
|
+
if (dryRun) {
|
|
2851
|
+
console.log(' Would restore files:');
|
|
2852
|
+
files.forEach(f => console.log(` - ${f}`));
|
|
2853
|
+
} else {
|
|
2854
|
+
files.forEach(f => {
|
|
2855
|
+
execSync(`git checkout HEAD~1 -- "${f}"`, { stdio: 'inherit' });
|
|
2856
|
+
});
|
|
2857
|
+
execSync(`git commit -m "chore: rollback ${files.join(', ')}"`, { stdio: 'inherit' });
|
|
2858
|
+
}
|
|
2859
|
+
} else if (method === 'branch') {
|
|
2860
|
+
const [startCommit, endCommit] = target.split('..');
|
|
2861
|
+
if (dryRun) {
|
|
2862
|
+
console.log(` Would revert range: ${startCommit}..${endCommit}`);
|
|
2863
|
+
const commits = execSync(`git log --oneline ${startCommit}..${endCommit}`, { encoding: 'utf-8' });
|
|
2864
|
+
console.log(' Commits to revert:');
|
|
2865
|
+
commits.trim().split('\n').forEach(c => console.log(` ${c}`));
|
|
2866
|
+
} else {
|
|
2867
|
+
execSync(`git revert --no-edit ${startCommit}..${endCommit}`, { stdio: 'inherit' });
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
if (!dryRun) {
|
|
2872
|
+
console.log(' 📦 Restoring user content...');
|
|
2873
|
+
preserveUserSections(agentsPath, savedSections);
|
|
2874
|
+
|
|
2875
|
+
// Amend commit to include restored USER sections
|
|
2876
|
+
if (fs.existsSync(agentsPath)) {
|
|
2877
|
+
execSync('git add AGENTS.md', { stdio: 'inherit' });
|
|
2878
|
+
execSync('git commit --amend --no-edit', { stdio: 'inherit' });
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
console.log('');
|
|
2882
|
+
console.log(chalk.green(' ✅ Rollback complete'));
|
|
2883
|
+
console.log(' User content preserved');
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
return true;
|
|
2887
|
+
} catch (err) {
|
|
2888
|
+
console.log('');
|
|
2889
|
+
console.log(chalk.red(' ❌ Rollback failed:'), err.message);
|
|
2890
|
+
console.log(' Try manual rollback with: git revert <commit>');
|
|
2891
|
+
return false;
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
// Interactive rollback menu
|
|
2896
|
+
async function showRollbackMenu() {
|
|
2897
|
+
console.log('');
|
|
2898
|
+
console.log(chalk.cyan.bold(' 🔄 Forge Rollback'));
|
|
2899
|
+
console.log('');
|
|
2900
|
+
console.log(' Choose rollback method:');
|
|
2901
|
+
console.log('');
|
|
2902
|
+
console.log(' 1. Rollback last commit');
|
|
2903
|
+
console.log(' 2. Rollback specific commit');
|
|
2904
|
+
console.log(' 3. Rollback merged PR');
|
|
2905
|
+
console.log(' 4. Rollback specific files only');
|
|
2906
|
+
console.log(' 5. Rollback entire branch');
|
|
2907
|
+
console.log(' 6. Preview rollback (dry run)');
|
|
2908
|
+
console.log('');
|
|
2909
|
+
|
|
2910
|
+
const rl = readline.createInterface({
|
|
2911
|
+
input: process.stdin,
|
|
2912
|
+
output: process.stdout
|
|
2913
|
+
});
|
|
2914
|
+
|
|
2915
|
+
const choice = await new Promise(resolve => {
|
|
2916
|
+
rl.question(' Enter choice (1-6): ', resolve);
|
|
2917
|
+
});
|
|
2918
|
+
|
|
2919
|
+
let method, target, dryRun = false;
|
|
2920
|
+
|
|
2921
|
+
switch (choice.trim()) {
|
|
2922
|
+
case '1':
|
|
2923
|
+
method = 'commit';
|
|
2924
|
+
target = 'HEAD';
|
|
2925
|
+
break;
|
|
2926
|
+
|
|
2927
|
+
case '2':
|
|
2928
|
+
target = await new Promise(resolve => {
|
|
2929
|
+
rl.question(' Enter commit hash: ', resolve);
|
|
2930
|
+
});
|
|
2931
|
+
method = 'commit';
|
|
2932
|
+
break;
|
|
2933
|
+
|
|
2934
|
+
case '3':
|
|
2935
|
+
target = await new Promise(resolve => {
|
|
2936
|
+
rl.question(' Enter merge commit hash: ', resolve);
|
|
2937
|
+
});
|
|
2938
|
+
method = 'pr';
|
|
2939
|
+
break;
|
|
2940
|
+
|
|
2941
|
+
case '4':
|
|
2942
|
+
target = await new Promise(resolve => {
|
|
2943
|
+
rl.question(' Enter file paths (comma-separated): ', resolve);
|
|
2944
|
+
});
|
|
2945
|
+
method = 'partial';
|
|
2946
|
+
break;
|
|
2947
|
+
|
|
2948
|
+
case '5':
|
|
2949
|
+
const start = await new Promise(resolve => {
|
|
2950
|
+
rl.question(' Enter start commit: ', resolve);
|
|
2951
|
+
});
|
|
2952
|
+
const end = await new Promise(resolve => {
|
|
2953
|
+
rl.question(' Enter end commit: ', resolve);
|
|
2954
|
+
});
|
|
2955
|
+
target = `${start.trim()}..${end.trim()}`;
|
|
2956
|
+
method = 'branch';
|
|
2957
|
+
break;
|
|
2958
|
+
|
|
2959
|
+
case '6':
|
|
2960
|
+
dryRun = true;
|
|
2961
|
+
const dryMethod = await new Promise(resolve => {
|
|
2962
|
+
rl.question(' Preview method (commit/pr/partial/branch): ', resolve);
|
|
2963
|
+
});
|
|
2964
|
+
method = dryMethod.trim();
|
|
2965
|
+
target = await new Promise(resolve => {
|
|
2966
|
+
rl.question(' Enter target (commit/files/range): ', resolve);
|
|
2967
|
+
});
|
|
2968
|
+
break;
|
|
2969
|
+
|
|
2970
|
+
default:
|
|
2971
|
+
console.log(chalk.red(' Invalid choice'));
|
|
2972
|
+
rl.close();
|
|
2973
|
+
return;
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
rl.close();
|
|
2977
|
+
|
|
2978
|
+
await performRollback(method, target, dryRun);
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2278
2981
|
main().catch(console.error);
|