feat-forge 1.0.1
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/LICENSE +661 -0
- package/README.md +350 -0
- package/dist/cli.js +306 -0
- package/dist/commands/AbstractCommands.js +16 -0
- package/dist/commands/AgentCommands.js +14 -0
- package/dist/commands/BranchCommands.js +400 -0
- package/dist/commands/CompletionCommands.js +702 -0
- package/dist/commands/EnvCommands.js +56 -0
- package/dist/commands/FeatureCommands.js +4 -0
- package/dist/commands/FixCommands.js +4 -0
- package/dist/commands/InitCommands.js +380 -0
- package/dist/commands/MaintenanceCommands.js +39 -0
- package/dist/commands/ModeCommands.js +15 -0
- package/dist/commands/ProxyCommands.js +14 -0
- package/dist/commands/ReleaseCommands.js +4 -0
- package/dist/commands/ServicesCommands.js +95 -0
- package/dist/commands/SubBranchCommands.js +49 -0
- package/dist/commands/types/InitOptions.js +1 -0
- package/dist/foundation/BranchContext.js +427 -0
- package/dist/foundation/ForgeConfig.js +264 -0
- package/dist/foundation/ForgeConfigFile.js +391 -0
- package/dist/foundation/ForgeContext.js +169 -0
- package/dist/foundation/NpmHelper.js +131 -0
- package/dist/foundation/PathHelper.js +56 -0
- package/dist/foundation/PortAllocator.js +192 -0
- package/dist/foundation/Proxy.js +176 -0
- package/dist/foundation/Repository.js +431 -0
- package/dist/foundation/errors/ForgeError.js +9 -0
- package/dist/foundation/errors/_error.config.js +12 -0
- package/dist/foundation/errors/generated/ForgeBadStateError.js +11 -0
- package/dist/foundation/errors/generated/ForgeConfigError.js +11 -0
- package/dist/foundation/errors/generated/ForgeExpectMainRepositoryError.js +11 -0
- package/dist/foundation/errors/generated/ForgeModeNotDefinedError.js +11 -0
- package/dist/foundation/errors/generated/ForgeNotInActiveBranchError.js +11 -0
- package/dist/foundation/errors/generated/ForgePortAllocationsLoadError.js +11 -0
- package/dist/foundation/errors/generated/ForgePortNotAssignedError.js +11 -0
- package/dist/foundation/errors/generated/ForgePortRangeExhaustedError.js +11 -0
- package/dist/foundation/errors/generated/ForgeServicesScanError.js +11 -0
- package/dist/foundation/errors/generated/ForgeServicesValidationError.js +11 -0
- package/dist/foundation/errors/index.js +13 -0
- package/dist/foundation/types/AIAgent.js +1 -0
- package/dist/foundation/types/AIAgentName.js +11 -0
- package/dist/foundation/types/DeepPartial.js +1 -0
- package/dist/foundation/types/IDE.js +1 -0
- package/dist/foundation/types/IDEName.js +7 -0
- package/dist/foundation/types/ModeConfig.js +1 -0
- package/dist/foundation/types/RepositoryInfos.js +1 -0
- package/dist/foundation/types/Services.js +156 -0
- package/dist/foundation/types/ShellName.js +11 -0
- package/dist/lib/agents.js +47 -0
- package/dist/lib/bootstrap.js +54 -0
- package/dist/lib/branch.js +4 -0
- package/dist/lib/config.js +65 -0
- package/dist/lib/constants.js +13 -0
- package/dist/lib/env.js +20 -0
- package/dist/lib/fs.js +156 -0
- package/dist/lib/git.js +170 -0
- package/dist/lib/hooks.js +98 -0
- package/dist/lib/ide.js +75 -0
- package/dist/lib/merger.js +103 -0
- package/dist/lib/platform.js +13 -0
- package/dist/lib/prompt.js +134 -0
- package/dist/lib/proxy-dashboard.js +75 -0
- package/dist/lib/scanner.js +118 -0
- package/dist/lib/services.js +132 -0
- package/dist/lib/slug.js +35 -0
- package/dist/lib/templates.js +115 -0
- package/dist/lib/validator.js +15 -0
- package/dist/templates/SPEC.md +21 -0
- package/dist/templates/TODO.md +5 -0
- package/dist/templates/agent/001.general.Omnibus.agent.md +4 -0
- package/dist/templates/agent/002.discovery.Inventorius.agent.md +4 -0
- package/dist/templates/agent/003.design.Architecturius.agent.md +8 -0
- package/dist/templates/agent/004.plan.Strategos.agent.md +8 -0
- package/dist/templates/agent/005.tdd.TestDrivenCodificius.agent.md +8 -0
- package/dist/templates/agent/006.code.Codificius.agent.md +8 -0
- package/dist/templates/agent/007.simplify.Consolidarius.agent.md +8 -0
- package/dist/templates/agent/008.review.Auditorix.agent.md +8 -0
- package/dist/templates/agent/009.testwriter.TestScriptor.agent.md +8 -0
- package/dist/templates/agent/010.testexecutor.TestExecutor.agent.md +8 -0
- package/dist/templates/agent/011.commit.Scribus.agent.md +10 -0
- package/dist/templates/agent/CONTEXT.code.md +145 -0
- package/dist/templates/agent/CONTEXT.spec.md +98 -0
- package/dist/templates/agent/Copilot/Code.agent.md +28 -0
- package/dist/templates/agent/Copilot/CodeCommit.agent.md +16 -0
- package/dist/templates/agent/Copilot/Feature-Builder.agent.md +49 -0
- package/dist/templates/agent/Copilot/Reviewer.agent.md +17 -0
- package/dist/templates/agent/Copilot/Simplifier.agent.md +21 -0
- package/dist/templates/agent/Copilot/Specs.agent.md +66 -0
- package/dist/templates/agent/Copilot/SpecsCommit.agent.md +19 -0
- package/dist/templates/agent/Copilot/TODO-Reader.agent.md +18 -0
- package/dist/templates/agent/Copilot/Tester.agent.md +12 -0
- package/package.json +76 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
import { Type } from 'class-transformer';
|
|
11
|
+
import { IsArray, IsDate, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min, ValidateNested } from 'class-validator';
|
|
12
|
+
/**
|
|
13
|
+
* Service definition from .forge/services.json (without port)
|
|
14
|
+
*/
|
|
15
|
+
export class ServiceDefinition {
|
|
16
|
+
name;
|
|
17
|
+
type = 'http'; // Phase 1: HTTP only, but extensible
|
|
18
|
+
path; // HTTP only, e.g., "/api"
|
|
19
|
+
}
|
|
20
|
+
__decorate([
|
|
21
|
+
IsString(),
|
|
22
|
+
__metadata("design:type", String)
|
|
23
|
+
], ServiceDefinition.prototype, "name", void 0);
|
|
24
|
+
__decorate([
|
|
25
|
+
IsOptional(),
|
|
26
|
+
IsEnum(['http', 'tcp', 'grpc']),
|
|
27
|
+
__metadata("design:type", String)
|
|
28
|
+
], ServiceDefinition.prototype, "type", void 0);
|
|
29
|
+
__decorate([
|
|
30
|
+
IsOptional(),
|
|
31
|
+
IsString(),
|
|
32
|
+
__metadata("design:type", String)
|
|
33
|
+
], ServiceDefinition.prototype, "path", void 0);
|
|
34
|
+
/**
|
|
35
|
+
* Services declaration from .forge/services.json
|
|
36
|
+
*/
|
|
37
|
+
export class ServicesDTO {
|
|
38
|
+
services;
|
|
39
|
+
}
|
|
40
|
+
__decorate([
|
|
41
|
+
IsArray(),
|
|
42
|
+
ValidateNested({ each: true }),
|
|
43
|
+
Type(() => ServiceDefinition),
|
|
44
|
+
__metadata("design:type", Array)
|
|
45
|
+
], ServicesDTO.prototype, "services", void 0);
|
|
46
|
+
/**
|
|
47
|
+
* Services for a single repository
|
|
48
|
+
*/
|
|
49
|
+
export class RepositoryServices {
|
|
50
|
+
/**
|
|
51
|
+
* Repository name
|
|
52
|
+
*/
|
|
53
|
+
name;
|
|
54
|
+
services;
|
|
55
|
+
}
|
|
56
|
+
__decorate([
|
|
57
|
+
IsString(),
|
|
58
|
+
IsNotEmpty(),
|
|
59
|
+
__metadata("design:type", String)
|
|
60
|
+
], RepositoryServices.prototype, "name", void 0);
|
|
61
|
+
__decorate([
|
|
62
|
+
ValidateNested({ each: true }),
|
|
63
|
+
Type(() => ServiceDefinition),
|
|
64
|
+
__metadata("design:type", Array)
|
|
65
|
+
], RepositoryServices.prototype, "services", void 0);
|
|
66
|
+
/**
|
|
67
|
+
* Service with assigned port (in generated.services.json)
|
|
68
|
+
*/
|
|
69
|
+
export class ServiceDefinitionWithPort extends ServiceDefinition {
|
|
70
|
+
port;
|
|
71
|
+
}
|
|
72
|
+
__decorate([
|
|
73
|
+
IsNumber(),
|
|
74
|
+
Min(1024),
|
|
75
|
+
Max(65535),
|
|
76
|
+
__metadata("design:type", Number)
|
|
77
|
+
], ServiceDefinitionWithPort.prototype, "port", void 0);
|
|
78
|
+
/**
|
|
79
|
+
* All repositories' services in a branch
|
|
80
|
+
*/
|
|
81
|
+
export class GeneratedServicesDTO {
|
|
82
|
+
_doNotEdit;
|
|
83
|
+
generatedAt;
|
|
84
|
+
services;
|
|
85
|
+
}
|
|
86
|
+
__decorate([
|
|
87
|
+
IsString(),
|
|
88
|
+
__metadata("design:type", String)
|
|
89
|
+
], GeneratedServicesDTO.prototype, "_doNotEdit", void 0);
|
|
90
|
+
__decorate([
|
|
91
|
+
IsDate(),
|
|
92
|
+
Type(() => Date),
|
|
93
|
+
__metadata("design:type", Date)
|
|
94
|
+
], GeneratedServicesDTO.prototype, "generatedAt", void 0);
|
|
95
|
+
__decorate([
|
|
96
|
+
IsArray(),
|
|
97
|
+
ValidateNested({ each: true }),
|
|
98
|
+
Type(() => ServiceDefinitionWithPort),
|
|
99
|
+
__metadata("design:type", Array)
|
|
100
|
+
], GeneratedServicesDTO.prototype, "services", void 0);
|
|
101
|
+
/**
|
|
102
|
+
* Root port allocations file structure
|
|
103
|
+
*/
|
|
104
|
+
export class PortAllocatorDTO {
|
|
105
|
+
_doNotEdit;
|
|
106
|
+
allocations;
|
|
107
|
+
}
|
|
108
|
+
__decorate([
|
|
109
|
+
IsString(),
|
|
110
|
+
__metadata("design:type", String)
|
|
111
|
+
], PortAllocatorDTO.prototype, "_doNotEdit", void 0);
|
|
112
|
+
__decorate([
|
|
113
|
+
IsArray(),
|
|
114
|
+
ValidateNested({ each: true }),
|
|
115
|
+
Type(() => BranchPortAllocationDTO),
|
|
116
|
+
__metadata("design:type", Array)
|
|
117
|
+
], PortAllocatorDTO.prototype, "allocations", void 0);
|
|
118
|
+
export class BranchPortAllocationDTO {
|
|
119
|
+
/**
|
|
120
|
+
* Branch name
|
|
121
|
+
*/
|
|
122
|
+
name;
|
|
123
|
+
/**
|
|
124
|
+
* Starting port number for this branch's allocation range
|
|
125
|
+
*/
|
|
126
|
+
start;
|
|
127
|
+
/**
|
|
128
|
+
* Ending port number for this branch's allocation range
|
|
129
|
+
*/
|
|
130
|
+
end;
|
|
131
|
+
/**
|
|
132
|
+
* Services with their allocated ports for this branch
|
|
133
|
+
*/
|
|
134
|
+
services; // Maps service name to its allocated port
|
|
135
|
+
}
|
|
136
|
+
__decorate([
|
|
137
|
+
IsString(),
|
|
138
|
+
IsNotEmpty(),
|
|
139
|
+
__metadata("design:type", String)
|
|
140
|
+
], BranchPortAllocationDTO.prototype, "name", void 0);
|
|
141
|
+
__decorate([
|
|
142
|
+
IsNumber(),
|
|
143
|
+
Min(1024),
|
|
144
|
+
__metadata("design:type", Number)
|
|
145
|
+
], BranchPortAllocationDTO.prototype, "start", void 0);
|
|
146
|
+
__decorate([
|
|
147
|
+
IsNumber(),
|
|
148
|
+
Max(65535),
|
|
149
|
+
__metadata("design:type", Number)
|
|
150
|
+
], BranchPortAllocationDTO.prototype, "end", void 0);
|
|
151
|
+
__decorate([
|
|
152
|
+
IsArray(),
|
|
153
|
+
ValidateNested({ each: true }),
|
|
154
|
+
Type(() => ServiceDefinitionWithPort),
|
|
155
|
+
__metadata("design:type", Array)
|
|
156
|
+
], BranchPortAllocationDTO.prototype, "services", void 0);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported shell types for completion script generation
|
|
3
|
+
*/
|
|
4
|
+
export var ShellName;
|
|
5
|
+
(function (ShellName) {
|
|
6
|
+
ShellName["Bash"] = "bash";
|
|
7
|
+
ShellName["Zsh"] = "zsh";
|
|
8
|
+
ShellName["Fish"] = "fish";
|
|
9
|
+
ShellName["PowerShell"] = "powershell";
|
|
10
|
+
ShellName["Pwsh"] = "pwsh";
|
|
11
|
+
})(ShellName || (ShellName = {}));
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { AIAgentName } from '../foundation/types/AIAgentName.js';
|
|
3
|
+
import { copyFilesWithTemplateReplacement, ensureDir } from './fs.js';
|
|
4
|
+
import { SOURCE_TEMPLATE_AGENT_PATH } from './templates.js';
|
|
5
|
+
export async function refreshCopilotAgentContextFiles(forgeContext, branchContext) {
|
|
6
|
+
// Need to create a .github/agents folder in the featureRoot workspace
|
|
7
|
+
const githubAgentsPath = path.join(branchContext.path, '.github', 'agents');
|
|
8
|
+
await ensureDir(githubAgentsPath);
|
|
9
|
+
const copilotSrcPath = path.join(SOURCE_TEMPLATE_AGENT_PATH, AIAgentName.COPILOT);
|
|
10
|
+
await copyFilesWithTemplateReplacement(forgeContext, branchContext, branchContext.mainRepo, copilotSrcPath, githubAgentsPath, {
|
|
11
|
+
overwrite: true,
|
|
12
|
+
dryRun: false,
|
|
13
|
+
}, SOURCE_TEMPLATE_AGENT_PATH);
|
|
14
|
+
// const copilotFiles = await readdir(path.join(SOURCE_TEMPLATE_AGENT_PATH, AIAgentName.COPILOT));
|
|
15
|
+
// // For Copilot, we basically create a symlink for each .agent.md file in :
|
|
16
|
+
// // - the .features/.template/agent/Copilot/ folder (if user doesn't have custom ones in their feature branch)
|
|
17
|
+
// // - the .features/<slug>/agent/Copilot/ folder
|
|
18
|
+
// // into the .github/agents folder of the feature workspace, so that they can be picked up by Copilot
|
|
19
|
+
// const templateCopilotAgentDir = path.join(branchContext.mainRepo.getAgentTemplatePath(AIAgentName.COPILOT));
|
|
20
|
+
// const branchCopilotAgentDir = path.join(branchContext.mainRepo.getAgentPath(branchContext.branchName, AIAgentName.COPILOT));
|
|
21
|
+
// let templateAgentFiles: string[] = [];
|
|
22
|
+
// let branchAgentFiles: string[] = [];
|
|
23
|
+
// let allAgentFiles: Map<string, string> = new Map();
|
|
24
|
+
// const matchRegex = /\.agent\.md$/;
|
|
25
|
+
// if (await pathExists(templateCopilotAgentDir)) {
|
|
26
|
+
// templateAgentFiles = await readdir(templateCopilotAgentDir);
|
|
27
|
+
// templateAgentFiles.forEach((file) => {
|
|
28
|
+
// if (file.match(matchRegex)) {
|
|
29
|
+
// allAgentFiles.set(file, path.join(templateCopilotAgentDir, file));
|
|
30
|
+
// }
|
|
31
|
+
// });
|
|
32
|
+
// }
|
|
33
|
+
// if (await pathExists(branchCopilotAgentDir)) {
|
|
34
|
+
// branchAgentFiles = await readdir(branchCopilotAgentDir);
|
|
35
|
+
// branchAgentFiles.forEach((file) => {
|
|
36
|
+
// if (file.match(matchRegex)) {
|
|
37
|
+
// allAgentFiles.set(file, path.join(branchCopilotAgentDir, file));
|
|
38
|
+
// }
|
|
39
|
+
// });
|
|
40
|
+
// }
|
|
41
|
+
// for (const agentFile of allAgentFiles.keys()) {
|
|
42
|
+
// const sourcePath = allAgentFiles.get(agentFile)!;
|
|
43
|
+
// const targetPath = path.join(githubAgentsPath, agentFile);
|
|
44
|
+
// await rm(targetPath, { force: true });
|
|
45
|
+
// await symlink(sourcePath, targetPath);
|
|
46
|
+
// }
|
|
47
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { pathExists } from './fs.js';
|
|
4
|
+
import { paramsToEnv } from './env.js';
|
|
5
|
+
import { getScriptExtension } from './platform.js';
|
|
6
|
+
/**
|
|
7
|
+
* Execute a script file if it exists.
|
|
8
|
+
* Gracefully skips execution if the script doesn't exist.
|
|
9
|
+
*
|
|
10
|
+
* @param scriptPath - Full path to the script file
|
|
11
|
+
* @param repositoryPath - Working directory for the script execution
|
|
12
|
+
* @param scriptName - Name of the script for logging (e.g., 'bootstrap')
|
|
13
|
+
* @param params - Optional parameters to pass to the script as environment variables
|
|
14
|
+
* @returns true if script was executed, false if it didn't exist
|
|
15
|
+
*/
|
|
16
|
+
export async function executeScript(scriptPath, repositoryPath, scriptName, params) {
|
|
17
|
+
if (!(await pathExists(scriptPath))) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
console.log(`🔄 Executing ${scriptName} script: ${scriptPath}`);
|
|
21
|
+
try {
|
|
22
|
+
const hookEnv = paramsToEnv(params);
|
|
23
|
+
await execa(scriptPath, {
|
|
24
|
+
cwd: repositoryPath,
|
|
25
|
+
stdio: 'inherit',
|
|
26
|
+
env: {
|
|
27
|
+
...process.env,
|
|
28
|
+
...hookEnv,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
console.log(`✅ ${scriptName} script completed successfully`);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
36
|
+
console.error(`❌ ${scriptName} script failed: ${message}`);
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Execute a bootstrap script from a repository if it exists.
|
|
42
|
+
* Automatically selects the correct script based on the operating system (.sh or .bat).
|
|
43
|
+
* Gracefully skips execution if the script doesn't exist.
|
|
44
|
+
*
|
|
45
|
+
* @param repositoryPath - Path to the repository/worktree
|
|
46
|
+
* @param repoConfigFolderPath - Relative path to the repo config folder (e.g., '.forge')
|
|
47
|
+
* @param scriptName - Name of the script without extension (e.g., 'bootstrap')
|
|
48
|
+
* @returns true if script was executed, false if it didn't exist
|
|
49
|
+
*/
|
|
50
|
+
export async function executeBootstrapScript(repositoryPath, repoConfigFolderPath, scriptName = 'bootstrap') {
|
|
51
|
+
const scriptExtension = getScriptExtension();
|
|
52
|
+
const scriptPath = path.join(repositoryPath, repoConfigFolderPath, `${scriptName}${scriptExtension}`);
|
|
53
|
+
return executeScript(scriptPath, repositoryPath, scriptName);
|
|
54
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ForgeConfigError } from '../foundation/errors/index.js';
|
|
2
|
+
import { ForgeConfigFile } from '../foundation/ForgeConfigFile.js';
|
|
3
|
+
import { plainToInstance } from 'class-transformer';
|
|
4
|
+
import { validate } from 'class-validator';
|
|
5
|
+
import { readFile } from 'fs/promises';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { ForgeConfig } from '../foundation/ForgeConfig.js';
|
|
8
|
+
import { ForgeContext } from '../foundation/ForgeContext.js';
|
|
9
|
+
import { FEAT_FORGE_CONFIG_FILE, FEAT_FORGE_CONFIG_FOLDER, FEAT_FORGE_HOME_CONFIG_FILE } from './constants.js';
|
|
10
|
+
import { pathExists } from './fs.js';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import { mergeDropUndefined } from './merger.js';
|
|
13
|
+
const HOME_CONFIG_PATH = path.join(os.homedir(), FEAT_FORGE_CONFIG_FOLDER);
|
|
14
|
+
export async function getHomeDirConfigFile() {
|
|
15
|
+
const configPath = path.join(HOME_CONFIG_PATH, FEAT_FORGE_HOME_CONFIG_FILE);
|
|
16
|
+
if (!(await pathExists(configPath))) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const raw = await readFile(configPath, 'utf8');
|
|
20
|
+
return JSON.parse(raw);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Find the nearest .feat-forge.json by walking up from startDir.
|
|
24
|
+
*/
|
|
25
|
+
export async function findConfigFilePath(startDir = process.cwd()) {
|
|
26
|
+
let current = path.resolve(startDir);
|
|
27
|
+
while (true) {
|
|
28
|
+
const configPath = path.join(current, FEAT_FORGE_CONFIG_FILE);
|
|
29
|
+
if (await pathExists(configPath)) {
|
|
30
|
+
return configPath;
|
|
31
|
+
}
|
|
32
|
+
const parent = path.dirname(current);
|
|
33
|
+
if (parent === current) {
|
|
34
|
+
throw new ForgeConfigError(`Missing ${FEAT_FORGE_CONFIG_FILE}. Run the CLI from a configured root folder or start with 'forge init'.`);
|
|
35
|
+
}
|
|
36
|
+
current = parent;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Load and validate the Forge config from the nearest root.
|
|
41
|
+
*/
|
|
42
|
+
export async function loadForgeConfig(startDir = process.cwd()) {
|
|
43
|
+
const configFilePath = await findConfigFilePath(startDir);
|
|
44
|
+
const configPath = path.dirname(configFilePath);
|
|
45
|
+
try {
|
|
46
|
+
const raw = await readFile(configFilePath, 'utf8');
|
|
47
|
+
const projectConfigFile = JSON.parse(raw);
|
|
48
|
+
const homeConfigFile = await getHomeDirConfigFile();
|
|
49
|
+
const forgeConfigFile = plainToInstance(ForgeConfigFile, homeConfigFile ? mergeDropUndefined(homeConfigFile, projectConfigFile) : projectConfigFile);
|
|
50
|
+
const errors = await validate(forgeConfigFile, { whitelist: true, validationError: { target: false, value: false } });
|
|
51
|
+
if (errors.length > 0) {
|
|
52
|
+
throw new ForgeConfigError(`Invalid config file at ${configFilePath}:\n${JSON.stringify(errors, null, 2)}`);
|
|
53
|
+
}
|
|
54
|
+
return { configPath, forgeConfig: new ForgeConfig(configPath, forgeConfigFile) };
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
const forgeConfigError = new ForgeConfigError(`Failed to load Forge config: ${err.message}`);
|
|
58
|
+
forgeConfigError.stack = err.stack;
|
|
59
|
+
throw forgeConfigError;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function loadForgeContext(startDir = process.cwd()) {
|
|
63
|
+
const { configPath, forgeConfig } = await loadForgeConfig(startDir);
|
|
64
|
+
return new ForgeContext(configPath, forgeConfig);
|
|
65
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const FEAT_FORGE_CONFIG_FILE = '.feat-forge.json';
|
|
2
|
+
export const FEAT_FORGE_CONFIG_FOLDER = '.feat-forge';
|
|
3
|
+
export const FEAT_FORGE_HOME_CONFIG_FILE = 'global.config.json';
|
|
4
|
+
export const TEMP_FOLDER = 'tmp';
|
|
5
|
+
export const TEMP_FEATURE_INIT_FOLDER = 'feature-init';
|
|
6
|
+
export const TEMP_FEATURE_ARCHIVE_FOLDER = 'feature-archive';
|
|
7
|
+
export const FEAT_FORGE_SERVICES_FILE = 'services.json';
|
|
8
|
+
export const FEAT_FORGE_GENERATED_SERVICES_FILE = 'generated.services.json';
|
|
9
|
+
export var TemporaryFolderType;
|
|
10
|
+
(function (TemporaryFolderType) {
|
|
11
|
+
TemporaryFolderType["BRANCH_INIT"] = "init";
|
|
12
|
+
TemporaryFolderType["BRANCH_ARCHIVE"] = "archive";
|
|
13
|
+
})(TemporaryFolderType || (TemporaryFolderType = {}));
|
package/dist/lib/env.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert hook parameters to environment variables with a FORGE_HOOK prefix
|
|
3
|
+
* @param params - Parameters object to convert
|
|
4
|
+
* @returns Object suitable for use as env in execa
|
|
5
|
+
*/
|
|
6
|
+
export function paramsToEnv(params) {
|
|
7
|
+
if (!params || Object.keys(params).length === 0) {
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
const env = {};
|
|
11
|
+
for (const [key, value] of Object.entries(params)) {
|
|
12
|
+
if (value !== null && value !== undefined) {
|
|
13
|
+
// Convert to FORGE_HOOK_PARAM_NAME format and stringify
|
|
14
|
+
const slugKey = key.replace(/[^a-zA-Z0-9_]/g, '_'); // Replace non-alphanumeric characters with underscores
|
|
15
|
+
const envKey = `FORGE_HOOK_${slugKey.toUpperCase()}`;
|
|
16
|
+
env[envKey] = String(value);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return env;
|
|
20
|
+
}
|
package/dist/lib/fs.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { access, mkdir, readdir, readFile, rename, unlink, writeFile } from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { replaceTemplateMarkers, resolveAgentFileCustomTemplate } from './templates.js';
|
|
4
|
+
import { validateInput } from './validator.js';
|
|
5
|
+
export async function pathExists(targetPath) {
|
|
6
|
+
try {
|
|
7
|
+
await access(targetPath);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Ensure a directory exists (mkdir -p behavior).
|
|
16
|
+
*/
|
|
17
|
+
export async function ensureDir(targetPath) {
|
|
18
|
+
await mkdir(targetPath, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Read a UTF-8 text file.
|
|
22
|
+
*/
|
|
23
|
+
export async function readTextFile(targetPath) {
|
|
24
|
+
return readFile(targetPath, { encoding: 'utf8' });
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Write a UTF-8 text file, replacing any existing content.
|
|
28
|
+
*/
|
|
29
|
+
export async function writeTextFile(targetPath, contents, makePath = true) {
|
|
30
|
+
if (makePath) {
|
|
31
|
+
const dir = path.dirname(targetPath);
|
|
32
|
+
await ensureDir(dir);
|
|
33
|
+
}
|
|
34
|
+
await writeFile(targetPath, contents, { encoding: 'utf8' });
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read a json file againt a class-validator/class-transformer class, validate and return the instance.
|
|
38
|
+
* @throws if file doesn't exist, is not valid JSON, or fails validation
|
|
39
|
+
* @returns an instance of the provided class with the file data
|
|
40
|
+
*/
|
|
41
|
+
export async function readJSONFile(classTransformerClass, targetPath) {
|
|
42
|
+
const content = await readTextFile(targetPath);
|
|
43
|
+
const data = JSON.parse(content);
|
|
44
|
+
return validateInput(classTransformerClass, data);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Write a config file atomically and create a timestamped backup if the file already exists.
|
|
48
|
+
* - If `targetPath` exists and `createBackup` is true, a backup named `${target}.bak.${iso}` is created.
|
|
49
|
+
* - Writes to a temporary file in the same directory, then renames into place.
|
|
50
|
+
*/
|
|
51
|
+
export async function writeConfigSafely(targetPath, contents, createBackup = true) {
|
|
52
|
+
const dir = path.dirname(targetPath);
|
|
53
|
+
await ensureDir(dir);
|
|
54
|
+
const exists = await pathExists(targetPath);
|
|
55
|
+
if (exists && createBackup) {
|
|
56
|
+
const iso = new Date().toISOString().replace(/[:]/g, '-');
|
|
57
|
+
const backupPath = `${targetPath}.bak.${iso}`;
|
|
58
|
+
await rename(targetPath, backupPath);
|
|
59
|
+
}
|
|
60
|
+
// write to temp file then rename
|
|
61
|
+
const tmpName = `.tmp.${path.basename(targetPath)}.${Date.now()}`;
|
|
62
|
+
const tmpPath = path.join(dir, tmpName);
|
|
63
|
+
try {
|
|
64
|
+
await writeFile(tmpPath, contents, { encoding: 'utf8' });
|
|
65
|
+
await rename(tmpPath, targetPath);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
// cleanup temp file if something went wrong
|
|
69
|
+
try {
|
|
70
|
+
if (await pathExists(tmpPath))
|
|
71
|
+
await unlink(tmpPath);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// ignore
|
|
75
|
+
}
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Ensure a line exists in a file (typically .gitignore).
|
|
81
|
+
* Appends the line if not present, creates the file if it doesn't exist.
|
|
82
|
+
* @returns number of lines added (0 or 1)
|
|
83
|
+
* @throws if the file exists but is not writable
|
|
84
|
+
*/
|
|
85
|
+
export async function ensureLineInFile(filePath, line) {
|
|
86
|
+
let content = '';
|
|
87
|
+
if (await pathExists(filePath)) {
|
|
88
|
+
content = await readTextFile(filePath);
|
|
89
|
+
}
|
|
90
|
+
const lines = content.split('\n');
|
|
91
|
+
const trimmedLine = line.trim();
|
|
92
|
+
// Check if line already exists
|
|
93
|
+
if (lines.some((l) => l.trim() === trimmedLine)) {
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
// Ensure file ends with newline before adding new line
|
|
97
|
+
const needsNewline = content.length > 0 && !content.endsWith('\n');
|
|
98
|
+
const newContent = needsNewline ? `${content}\n${trimmedLine}\n` : `${content}${trimmedLine}\n`;
|
|
99
|
+
await writeTextFile(filePath, newContent);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
export async function copyFilesRecursively(srcDir, destDir, options = {}) {
|
|
103
|
+
const { overwrite = false, dryRun = true } = options;
|
|
104
|
+
let fileChanges = [];
|
|
105
|
+
const entries = await readdir(srcDir, { withFileTypes: true });
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const srcPath = path.join(srcDir, entry.name);
|
|
108
|
+
const destPath = path.join(destDir, entry.name);
|
|
109
|
+
if (entry.isDirectory()) {
|
|
110
|
+
await ensureDir(destPath);
|
|
111
|
+
fileChanges = [...fileChanges, ...(await copyFilesRecursively(srcPath, destPath, options))];
|
|
112
|
+
}
|
|
113
|
+
else if (entry.isFile()) {
|
|
114
|
+
const destExists = await pathExists(destPath);
|
|
115
|
+
if (!overwrite && destExists)
|
|
116
|
+
continue; // don't overwrite existing files unless overwrite flag is set
|
|
117
|
+
if (!dryRun) {
|
|
118
|
+
let content = await readFile(srcPath, 'utf8');
|
|
119
|
+
await writeTextFile(destPath, content);
|
|
120
|
+
}
|
|
121
|
+
// Record change only if writing or would write
|
|
122
|
+
fileChanges.push(destPath);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return fileChanges;
|
|
126
|
+
}
|
|
127
|
+
export async function copyFilesWithTemplateReplacement(forgeContext, branchContext, repository, srcDir, destDir, options = {}, baseSrcDir = srcDir) {
|
|
128
|
+
const { overwrite = false, dryRun = true } = options;
|
|
129
|
+
let fileChanges = [];
|
|
130
|
+
const entries = await readdir(srcDir, { withFileTypes: true });
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
const srcEntryPath = path.join(srcDir, entry.name);
|
|
133
|
+
const destPath = path.join(destDir, entry.name);
|
|
134
|
+
if (entry.isDirectory()) {
|
|
135
|
+
await ensureDir(destPath);
|
|
136
|
+
fileChanges = [
|
|
137
|
+
...fileChanges,
|
|
138
|
+
...(await copyFilesWithTemplateReplacement(forgeContext, branchContext, repository, srcEntryPath, destPath, options, baseSrcDir)),
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
else if (entry.isFile()) {
|
|
142
|
+
const destExists = await pathExists(destPath);
|
|
143
|
+
if (!overwrite && destExists)
|
|
144
|
+
continue; // don't overwrite existing files unless overwrite flag is set
|
|
145
|
+
if (!dryRun) {
|
|
146
|
+
let entryRelativePath = path.relative(baseSrcDir, srcEntryPath);
|
|
147
|
+
let content = await resolveAgentFileCustomTemplate(forgeContext, repository, entryRelativePath);
|
|
148
|
+
content = replaceTemplateMarkers(content, forgeContext, branchContext, repository);
|
|
149
|
+
await writeTextFile(destPath, content);
|
|
150
|
+
}
|
|
151
|
+
// Record change only if writing or would write
|
|
152
|
+
fileChanges.push(destPath);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return fileChanges;
|
|
156
|
+
}
|