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.
Files changed (93) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +350 -0
  3. package/dist/cli.js +306 -0
  4. package/dist/commands/AbstractCommands.js +16 -0
  5. package/dist/commands/AgentCommands.js +14 -0
  6. package/dist/commands/BranchCommands.js +400 -0
  7. package/dist/commands/CompletionCommands.js +702 -0
  8. package/dist/commands/EnvCommands.js +56 -0
  9. package/dist/commands/FeatureCommands.js +4 -0
  10. package/dist/commands/FixCommands.js +4 -0
  11. package/dist/commands/InitCommands.js +380 -0
  12. package/dist/commands/MaintenanceCommands.js +39 -0
  13. package/dist/commands/ModeCommands.js +15 -0
  14. package/dist/commands/ProxyCommands.js +14 -0
  15. package/dist/commands/ReleaseCommands.js +4 -0
  16. package/dist/commands/ServicesCommands.js +95 -0
  17. package/dist/commands/SubBranchCommands.js +49 -0
  18. package/dist/commands/types/InitOptions.js +1 -0
  19. package/dist/foundation/BranchContext.js +427 -0
  20. package/dist/foundation/ForgeConfig.js +264 -0
  21. package/dist/foundation/ForgeConfigFile.js +391 -0
  22. package/dist/foundation/ForgeContext.js +169 -0
  23. package/dist/foundation/NpmHelper.js +131 -0
  24. package/dist/foundation/PathHelper.js +56 -0
  25. package/dist/foundation/PortAllocator.js +192 -0
  26. package/dist/foundation/Proxy.js +176 -0
  27. package/dist/foundation/Repository.js +431 -0
  28. package/dist/foundation/errors/ForgeError.js +9 -0
  29. package/dist/foundation/errors/_error.config.js +12 -0
  30. package/dist/foundation/errors/generated/ForgeBadStateError.js +11 -0
  31. package/dist/foundation/errors/generated/ForgeConfigError.js +11 -0
  32. package/dist/foundation/errors/generated/ForgeExpectMainRepositoryError.js +11 -0
  33. package/dist/foundation/errors/generated/ForgeModeNotDefinedError.js +11 -0
  34. package/dist/foundation/errors/generated/ForgeNotInActiveBranchError.js +11 -0
  35. package/dist/foundation/errors/generated/ForgePortAllocationsLoadError.js +11 -0
  36. package/dist/foundation/errors/generated/ForgePortNotAssignedError.js +11 -0
  37. package/dist/foundation/errors/generated/ForgePortRangeExhaustedError.js +11 -0
  38. package/dist/foundation/errors/generated/ForgeServicesScanError.js +11 -0
  39. package/dist/foundation/errors/generated/ForgeServicesValidationError.js +11 -0
  40. package/dist/foundation/errors/index.js +13 -0
  41. package/dist/foundation/types/AIAgent.js +1 -0
  42. package/dist/foundation/types/AIAgentName.js +11 -0
  43. package/dist/foundation/types/DeepPartial.js +1 -0
  44. package/dist/foundation/types/IDE.js +1 -0
  45. package/dist/foundation/types/IDEName.js +7 -0
  46. package/dist/foundation/types/ModeConfig.js +1 -0
  47. package/dist/foundation/types/RepositoryInfos.js +1 -0
  48. package/dist/foundation/types/Services.js +156 -0
  49. package/dist/foundation/types/ShellName.js +11 -0
  50. package/dist/lib/agents.js +47 -0
  51. package/dist/lib/bootstrap.js +54 -0
  52. package/dist/lib/branch.js +4 -0
  53. package/dist/lib/config.js +65 -0
  54. package/dist/lib/constants.js +13 -0
  55. package/dist/lib/env.js +20 -0
  56. package/dist/lib/fs.js +156 -0
  57. package/dist/lib/git.js +170 -0
  58. package/dist/lib/hooks.js +98 -0
  59. package/dist/lib/ide.js +75 -0
  60. package/dist/lib/merger.js +103 -0
  61. package/dist/lib/platform.js +13 -0
  62. package/dist/lib/prompt.js +134 -0
  63. package/dist/lib/proxy-dashboard.js +75 -0
  64. package/dist/lib/scanner.js +118 -0
  65. package/dist/lib/services.js +132 -0
  66. package/dist/lib/slug.js +35 -0
  67. package/dist/lib/templates.js +115 -0
  68. package/dist/lib/validator.js +15 -0
  69. package/dist/templates/SPEC.md +21 -0
  70. package/dist/templates/TODO.md +5 -0
  71. package/dist/templates/agent/001.general.Omnibus.agent.md +4 -0
  72. package/dist/templates/agent/002.discovery.Inventorius.agent.md +4 -0
  73. package/dist/templates/agent/003.design.Architecturius.agent.md +8 -0
  74. package/dist/templates/agent/004.plan.Strategos.agent.md +8 -0
  75. package/dist/templates/agent/005.tdd.TestDrivenCodificius.agent.md +8 -0
  76. package/dist/templates/agent/006.code.Codificius.agent.md +8 -0
  77. package/dist/templates/agent/007.simplify.Consolidarius.agent.md +8 -0
  78. package/dist/templates/agent/008.review.Auditorix.agent.md +8 -0
  79. package/dist/templates/agent/009.testwriter.TestScriptor.agent.md +8 -0
  80. package/dist/templates/agent/010.testexecutor.TestExecutor.agent.md +8 -0
  81. package/dist/templates/agent/011.commit.Scribus.agent.md +10 -0
  82. package/dist/templates/agent/CONTEXT.code.md +145 -0
  83. package/dist/templates/agent/CONTEXT.spec.md +98 -0
  84. package/dist/templates/agent/Copilot/Code.agent.md +28 -0
  85. package/dist/templates/agent/Copilot/CodeCommit.agent.md +16 -0
  86. package/dist/templates/agent/Copilot/Feature-Builder.agent.md +49 -0
  87. package/dist/templates/agent/Copilot/Reviewer.agent.md +17 -0
  88. package/dist/templates/agent/Copilot/Simplifier.agent.md +21 -0
  89. package/dist/templates/agent/Copilot/Specs.agent.md +66 -0
  90. package/dist/templates/agent/Copilot/SpecsCommit.agent.md +19 -0
  91. package/dist/templates/agent/Copilot/TODO-Reader.agent.md +18 -0
  92. package/dist/templates/agent/Copilot/Tester.agent.md +12 -0
  93. 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,4 @@
1
+ import path from 'path';
2
+ export function branchNameAsPath(branchName) {
3
+ return branchName.split('/').join(path.sep);
4
+ }
@@ -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 = {}));
@@ -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
+ }