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,56 @@
|
|
|
1
|
+
import { AbstractCommands } from './AbstractCommands.js';
|
|
2
|
+
import { BranchContext } from '../foundation/BranchContext.js';
|
|
3
|
+
import { generateEnvrcFile, loadGeneratedServicesFile } from '../lib/services.js';
|
|
4
|
+
import { readTextFile, pathExists } from '../lib/fs.js';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
export class EnvCommands extends AbstractCommands {
|
|
7
|
+
constructor(context) {
|
|
8
|
+
super(context);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Generate .envrc from existing generated.services.json
|
|
12
|
+
*/
|
|
13
|
+
async update(branchName) {
|
|
14
|
+
// Load branch context
|
|
15
|
+
const branchContext = branchName
|
|
16
|
+
? await this.context.loadBranchContext(branchName)
|
|
17
|
+
: await BranchContext.findNearestBranchContext(this.context);
|
|
18
|
+
const generatedServicesPath = path.join(branchContext.path, 'generated.services.json');
|
|
19
|
+
if (!(await pathExists(generatedServicesPath))) {
|
|
20
|
+
console.log('❌ No generated.services.json found. Run "forge services scan" first.');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const generatedServicesFile = await loadGeneratedServicesFile(branchContext);
|
|
24
|
+
// Read generated services
|
|
25
|
+
await this.generateEnvrcFile(branchContext, generatedServicesFile.services);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Display current .envrc and port allocation info
|
|
29
|
+
*/
|
|
30
|
+
async show(branchName) {
|
|
31
|
+
// Load branch context
|
|
32
|
+
const branchContext = branchName
|
|
33
|
+
? await this.context.loadBranchContext(branchName)
|
|
34
|
+
: await BranchContext.findNearestBranchContext(this.context);
|
|
35
|
+
const envrcPath = path.join(branchContext.path, '.envrc');
|
|
36
|
+
console.log(`📋 Environment configuration for branch "${branchContext.branchName}"`);
|
|
37
|
+
console.log('');
|
|
38
|
+
// Check if .envrc exists
|
|
39
|
+
if (!(await pathExists(envrcPath))) {
|
|
40
|
+
console.log('❌ No .envrc found. Run "forge env update" first.');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Read and display .envrc
|
|
44
|
+
const envrcContent = await readTextFile(envrcPath);
|
|
45
|
+
console.log('=== .envrc content ===');
|
|
46
|
+
console.log(envrcContent);
|
|
47
|
+
console.log('');
|
|
48
|
+
}
|
|
49
|
+
async generateEnvrcFile(branchContext, services) {
|
|
50
|
+
console.log(`🔄 Generating .envrc for branch "${branchContext.branchName}"...`);
|
|
51
|
+
const env = await generateEnvrcFile(this.context, branchContext, services);
|
|
52
|
+
console.log(` ✅ .envrc: ${env.path}`);
|
|
53
|
+
console.log('');
|
|
54
|
+
return env;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { ForgeConfigError } from '../foundation/errors/index.js';
|
|
2
|
+
import { AIAgentName } from '../foundation/types/AIAgentName.js';
|
|
3
|
+
import { IDEName } from '../foundation/types/IDEName.js';
|
|
4
|
+
import { FEAT_FORGE_CONFIG_FILE } from '../lib/constants.js';
|
|
5
|
+
import { pathExists, writeConfigSafely } from '../lib/fs.js';
|
|
6
|
+
import { promptCheckbox, promptChoice, promptConfirm, promptText } from '../lib/prompt.js';
|
|
7
|
+
import { findAncestorForgeConfig, scanGitRepos } from '../lib/scanner.js';
|
|
8
|
+
import { stat } from 'fs/promises';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
// Constants for UI strings
|
|
11
|
+
const DEFAULT_LABEL = ' (default)';
|
|
12
|
+
const FEATURES_FOLDER = '.features';
|
|
13
|
+
const VSCODE_FOLDER = '.vscode';
|
|
14
|
+
const IDEA_FOLDER = '.idea';
|
|
15
|
+
export class InitCommands {
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// PUBLIC COMMAND METHODS
|
|
18
|
+
// ============================================================================
|
|
19
|
+
/**
|
|
20
|
+
* Create a .feat-forge.json in the current working directory.
|
|
21
|
+
* Supports both interactive and non-interactive modes via options.
|
|
22
|
+
*
|
|
23
|
+
* @param options - Configuration options for init behavior
|
|
24
|
+
*/
|
|
25
|
+
async init(options = {}) {
|
|
26
|
+
const workingDir = options.path ? path.resolve(options.path) : process.cwd();
|
|
27
|
+
const targetPath = path.join(workingDir, FEAT_FORGE_CONFIG_FILE);
|
|
28
|
+
const isInteractive = !options.nonInteractive && !options.yes;
|
|
29
|
+
const isQuiet = options.quiet || false;
|
|
30
|
+
// Validate environment and check for conflicts
|
|
31
|
+
await this.validateInitEnvironment(targetPath, workingDir, options.force || false, isInteractive);
|
|
32
|
+
// Gather repository paths
|
|
33
|
+
const repoPaths = await this.gatherRepositoryPaths(workingDir, options.repositories, isInteractive, options.nonInteractive || false, options.yes || false);
|
|
34
|
+
// Validate repository paths exist
|
|
35
|
+
await this.validateRepositoryPaths(repoPaths, workingDir);
|
|
36
|
+
// Build configuration
|
|
37
|
+
const configFile = await this.buildConfiguration(repoPaths, workingDir, options.rootDir, options.agents, options.ides, isInteractive, options.yes || false);
|
|
38
|
+
// Confirm and write configuration
|
|
39
|
+
await this.confirmAndWriteConfig(configFile, targetPath, isInteractive, isQuiet);
|
|
40
|
+
if (!isQuiet) {
|
|
41
|
+
console.log(`✓ Initialized ${FEAT_FORGE_CONFIG_FILE} at ${targetPath}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// PRIVATE HELPER METHODS
|
|
46
|
+
// ============================================================================
|
|
47
|
+
/**
|
|
48
|
+
* Validate that the environment is suitable for initialization.
|
|
49
|
+
* Checks for ancestor configs and handles existing config files.
|
|
50
|
+
*/
|
|
51
|
+
async validateInitEnvironment(targetPath, workingDir, force, isInteractive) {
|
|
52
|
+
// Check for ancestor configuration
|
|
53
|
+
const ancestor = await findAncestorForgeConfig(workingDir);
|
|
54
|
+
if (ancestor) {
|
|
55
|
+
throw new ForgeConfigError(`An ancestor configuration already exists at ${ancestor}. Aborting init.`);
|
|
56
|
+
}
|
|
57
|
+
// Handle existing config file
|
|
58
|
+
if (await pathExists(targetPath)) {
|
|
59
|
+
if (force) {
|
|
60
|
+
// Force mode: will create backup via writeConfigSafely
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!isInteractive) {
|
|
64
|
+
throw new ForgeConfigError(`Configuration already exists at ${targetPath}. Use --force to overwrite.`);
|
|
65
|
+
}
|
|
66
|
+
const overwrite = await promptConfirm(`Configuration already exists at ${targetPath}. Overwrite?`);
|
|
67
|
+
if (!overwrite) {
|
|
68
|
+
console.log('Initialization cancelled.');
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Gather repository paths through scanning and user interaction.
|
|
75
|
+
*/
|
|
76
|
+
async gatherRepositoryPaths(workingDir, repositoriesFlag, isInteractive = true, nonInteractive = false, useDefaults = false) {
|
|
77
|
+
// If repositories provided via flag, use those
|
|
78
|
+
if (repositoriesFlag) {
|
|
79
|
+
return this.parseStringOrJSON(repositoriesFlag);
|
|
80
|
+
}
|
|
81
|
+
// In non-interactive mode without repositories flag, must fail
|
|
82
|
+
if (nonInteractive) {
|
|
83
|
+
throw new ForgeConfigError('Missing required --repositories flag in non-interactive mode. ' + 'Provide as comma-separated list or JSON array.');
|
|
84
|
+
}
|
|
85
|
+
const repos = await scanGitRepos(workingDir);
|
|
86
|
+
// Handle no repositories found
|
|
87
|
+
if (repos.length === 0) {
|
|
88
|
+
return await this.handleNoRepositoriesFound(isInteractive);
|
|
89
|
+
}
|
|
90
|
+
const repoPaths = repos.map((r) => this.makeRelativePath(r.path, workingDir));
|
|
91
|
+
// In --yes mode, use discovered repositories automatically
|
|
92
|
+
if (useDefaults) {
|
|
93
|
+
console.log('Discovered repositories:');
|
|
94
|
+
repoPaths.forEach((r) => console.log(` - ${r}`));
|
|
95
|
+
return repoPaths;
|
|
96
|
+
}
|
|
97
|
+
// Present discovered repositories to user with checkbox selection
|
|
98
|
+
console.log('Discovered repositories:');
|
|
99
|
+
repoPaths.forEach((r) => console.log(` - ${r}`));
|
|
100
|
+
const choices = repoPaths.map((r) => ({
|
|
101
|
+
name: r,
|
|
102
|
+
value: r,
|
|
103
|
+
checked: true,
|
|
104
|
+
}));
|
|
105
|
+
choices.push({
|
|
106
|
+
name: 'Add custom path...',
|
|
107
|
+
value: '__custom__',
|
|
108
|
+
checked: false,
|
|
109
|
+
});
|
|
110
|
+
const selected = await promptCheckbox('Select repositories to include in the config:', choices);
|
|
111
|
+
// Handle custom path addition
|
|
112
|
+
if (selected.includes('__custom__')) {
|
|
113
|
+
const customPath = await promptText('Enter custom repository path (relative to this folder):');
|
|
114
|
+
if (customPath && customPath.trim()) {
|
|
115
|
+
selected.splice(selected.indexOf('__custom__'), 1);
|
|
116
|
+
selected.push(customPath.trim());
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
selected.splice(selected.indexOf('__custom__'), 1);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return selected.length > 0 ? selected : ['.'];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Handle the case when no git repositories are found.
|
|
126
|
+
*/
|
|
127
|
+
async handleNoRepositoriesFound(isInteractive) {
|
|
128
|
+
if (!isInteractive) {
|
|
129
|
+
// Non-interactive: default to current directory
|
|
130
|
+
return ['.'];
|
|
131
|
+
}
|
|
132
|
+
const useCwd = await promptConfirm('No git repositories found. Use current directory as repository?');
|
|
133
|
+
if (useCwd) {
|
|
134
|
+
return ['.'];
|
|
135
|
+
}
|
|
136
|
+
const manual = await promptText('Enter repository path to add (relative to this folder):');
|
|
137
|
+
return [manual || '.'];
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Build the complete configuration object.
|
|
141
|
+
*/
|
|
142
|
+
async buildConfiguration(repoPaths, workingDir, rootDirFlag, agentsFlag, idesFlag, isInteractive = true, useDefaults = false) {
|
|
143
|
+
const repositories = await this.selectMainRepository(repoPaths, workingDir, isInteractive, useDefaults);
|
|
144
|
+
const agents = await this.configureAgents(agentsFlag, isInteractive, useDefaults);
|
|
145
|
+
const ides = await this.configureIDEs(workingDir, idesFlag, isInteractive, useDefaults);
|
|
146
|
+
return {
|
|
147
|
+
rootDir: rootDirFlag || '.',
|
|
148
|
+
repositories,
|
|
149
|
+
agents,
|
|
150
|
+
ides,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Display configuration summary and confirm before writing.
|
|
155
|
+
*/
|
|
156
|
+
async confirmAndWriteConfig(configFile, targetPath, isInteractive, isQuiet) {
|
|
157
|
+
if (!isQuiet) {
|
|
158
|
+
console.log('\n=== Configuration Summary ===');
|
|
159
|
+
console.log(JSON.stringify(configFile, null, 4));
|
|
160
|
+
console.log('=============================\n');
|
|
161
|
+
}
|
|
162
|
+
if (isInteractive) {
|
|
163
|
+
const confirmed = await promptConfirm('Write this configuration to .feat-forge.json?');
|
|
164
|
+
if (!confirmed) {
|
|
165
|
+
console.log('Initialization cancelled.');
|
|
166
|
+
process.exit(0);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const contents = JSON.stringify(configFile, null, 4);
|
|
170
|
+
await writeConfigSafely(targetPath, `${contents}\n`);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Convert an absolute path to a relative path from working directory.
|
|
174
|
+
* Returns '.' if the path is the current directory.
|
|
175
|
+
*/
|
|
176
|
+
makeRelativePath(absolutePath, workingDir) {
|
|
177
|
+
return path.relative(workingDir, absolutePath) || '.';
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Parse comma-separated input string into trimmed array.
|
|
181
|
+
*/
|
|
182
|
+
parseCommaSeparated(input) {
|
|
183
|
+
return input
|
|
184
|
+
.split(',')
|
|
185
|
+
.map((s) => s.trim())
|
|
186
|
+
.filter(Boolean);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Parse string input that could be comma-separated or JSON array.
|
|
190
|
+
*/
|
|
191
|
+
parseStringOrJSON(input) {
|
|
192
|
+
const trimmed = input.trim();
|
|
193
|
+
// Try to parse as JSON array first
|
|
194
|
+
if (trimmed.startsWith('[')) {
|
|
195
|
+
try {
|
|
196
|
+
const parsed = JSON.parse(trimmed);
|
|
197
|
+
if (Array.isArray(parsed)) {
|
|
198
|
+
return parsed.map(String).filter(Boolean);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
throw new ForgeConfigError(`Invalid JSON array: ${trimmed}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Otherwise treat as comma-separated
|
|
206
|
+
return this.parseCommaSeparated(trimmed);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Validate that repository paths exist and are directories.
|
|
210
|
+
*/
|
|
211
|
+
async validateRepositoryPaths(repoPaths, workingDir) {
|
|
212
|
+
for (const repoPath of repoPaths) {
|
|
213
|
+
const fullPath = path.join(workingDir, repoPath);
|
|
214
|
+
if (!(await pathExists(fullPath))) {
|
|
215
|
+
throw new ForgeConfigError(`Repository path does not exist: ${repoPath}`);
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const stats = await stat(fullPath);
|
|
219
|
+
if (!stats.isDirectory()) {
|
|
220
|
+
throw new ForgeConfigError(`Repository path is not a directory: ${repoPath}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
throw new ForgeConfigError(`Cannot access repository path: ${repoPath}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Select the main repository from the list of repository paths.
|
|
230
|
+
* Auto-selects if one has a .features folder, otherwise prompts the user.
|
|
231
|
+
*/
|
|
232
|
+
async selectMainRepository(repoPaths, workingDir, isInteractive, useDefaults) {
|
|
233
|
+
// Handle empty or single repository cases
|
|
234
|
+
if (repoPaths.length === 0) {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
if (repoPaths.length === 1) {
|
|
238
|
+
return [{ path: repoPaths[0], main: true }];
|
|
239
|
+
}
|
|
240
|
+
// Determine main repository from multiple options
|
|
241
|
+
const mainRepoPath = await this.determineMainRepository(repoPaths, workingDir, isInteractive, useDefaults);
|
|
242
|
+
// Build repository config entries with main flag
|
|
243
|
+
return repoPaths.map((rp) => ({
|
|
244
|
+
path: rp,
|
|
245
|
+
main: rp === mainRepoPath,
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Determine which repository should be marked as main.
|
|
250
|
+
* Auto-selects if exactly one has .features folder, otherwise prompts.
|
|
251
|
+
*/
|
|
252
|
+
async determineMainRepository(repoPaths, workingDir, isInteractive, useDefaults) {
|
|
253
|
+
const reposWithFeatures = await this.findRepositoriesWithFeatures(repoPaths, workingDir);
|
|
254
|
+
// Auto-select if exactly one repo has .features folder
|
|
255
|
+
if (reposWithFeatures.length === 1) {
|
|
256
|
+
const mainRepo = reposWithFeatures[0];
|
|
257
|
+
console.log(`Auto-selected '${mainRepo}' as main repository (has ${FEATURES_FOLDER} folder).`);
|
|
258
|
+
return mainRepo;
|
|
259
|
+
}
|
|
260
|
+
// If using defaults or non-interactive, pick first repo
|
|
261
|
+
if (!isInteractive || useDefaults) {
|
|
262
|
+
return repoPaths[0];
|
|
263
|
+
}
|
|
264
|
+
// Multiple repos with .features - let user choose
|
|
265
|
+
if (reposWithFeatures.length > 1) {
|
|
266
|
+
console.log(`\nMultiple repositories have ${FEATURES_FOLDER} folders. Select the main repository:`);
|
|
267
|
+
return await this.promptForMainRepository(reposWithFeatures, false);
|
|
268
|
+
}
|
|
269
|
+
// No repos with .features - prompt with first as default
|
|
270
|
+
console.log('\nMultiple repositories found. Select the main repository:');
|
|
271
|
+
return await this.promptForMainRepository(repoPaths, true);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Find which repositories contain a .features folder.
|
|
275
|
+
*/
|
|
276
|
+
async findRepositoriesWithFeatures(repoPaths, workingDir) {
|
|
277
|
+
const reposWithFeatures = [];
|
|
278
|
+
for (const repoPath of repoPaths) {
|
|
279
|
+
const fullPath = path.join(workingDir, repoPath);
|
|
280
|
+
const featuresPath = path.join(fullPath, FEATURES_FOLDER);
|
|
281
|
+
if (await pathExists(featuresPath)) {
|
|
282
|
+
reposWithFeatures.push(repoPath);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return reposWithFeatures;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Prompt user to select main repository from list.
|
|
289
|
+
* @param repos - List of repository paths to choose from
|
|
290
|
+
* @param markFirstAsDefault - Whether to mark the first option as default
|
|
291
|
+
*/
|
|
292
|
+
async promptForMainRepository(repos, markFirstAsDefault) {
|
|
293
|
+
const choices = repos.map((rp, idx) => ({
|
|
294
|
+
value: String(idx + 1),
|
|
295
|
+
name: `${rp}${markFirstAsDefault && idx === 0 ? DEFAULT_LABEL : ''}`,
|
|
296
|
+
}));
|
|
297
|
+
const selection = await promptChoice('Choose main repository:', choices);
|
|
298
|
+
const selectedChoice = choices.find((c) => c.value === selection);
|
|
299
|
+
if (selectedChoice) {
|
|
300
|
+
return selectedChoice.name.replace(DEFAULT_LABEL, '');
|
|
301
|
+
}
|
|
302
|
+
// Fallback to first repository if selection failed
|
|
303
|
+
return repos[0];
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Configure agents for the project.
|
|
307
|
+
* Suggests default agents and allows user to customize via checkboxes.
|
|
308
|
+
*/
|
|
309
|
+
async configureAgents(agentsFlag, isInteractive = true, useDefaults = false) {
|
|
310
|
+
// If agents provided via flag, use those
|
|
311
|
+
if (agentsFlag) {
|
|
312
|
+
const agentNames = this.parseStringOrJSON(agentsFlag);
|
|
313
|
+
return agentNames.length > 0 ? agentNames : undefined;
|
|
314
|
+
}
|
|
315
|
+
// If using defaults mode, return default agents
|
|
316
|
+
if (useDefaults) {
|
|
317
|
+
return [AIAgentName.COPILOT];
|
|
318
|
+
}
|
|
319
|
+
// Non-interactive: use ForgeConfig defaults (undefined)
|
|
320
|
+
if (!isInteractive) {
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
console.log('\n--- Agent Configuration ---');
|
|
324
|
+
// Build checkbox choices for all available agents
|
|
325
|
+
const availableAgents = Object.values(AIAgentName);
|
|
326
|
+
const choices = availableAgents.map((agent) => ({
|
|
327
|
+
name: agent,
|
|
328
|
+
value: agent,
|
|
329
|
+
checked: agent === AIAgentName.COPILOT, // Pre-check COPILOT as default
|
|
330
|
+
}));
|
|
331
|
+
const selected = await promptCheckbox('Select agents to enable:', choices);
|
|
332
|
+
return selected.length > 0 ? selected : undefined;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Configure IDEs for the project.
|
|
336
|
+
* Detects .vscode or .idea folders and allows selection via checkboxes.
|
|
337
|
+
*/
|
|
338
|
+
async configureIDEs(workingDir, idesFlag, isInteractive = true, useDefaults = false) {
|
|
339
|
+
// If IDEs provided via flag, use those
|
|
340
|
+
if (idesFlag) {
|
|
341
|
+
const ideNames = this.parseStringOrJSON(idesFlag);
|
|
342
|
+
return ideNames.length > 0 ? ideNames : undefined;
|
|
343
|
+
}
|
|
344
|
+
const detectedIDEs = await this.detectIDEs(workingDir);
|
|
345
|
+
// If using defaults mode, use detected IDEs or undefined
|
|
346
|
+
if (useDefaults) {
|
|
347
|
+
return detectedIDEs.length > 0 ? detectedIDEs : undefined;
|
|
348
|
+
}
|
|
349
|
+
// Non-interactive: use detected IDEs or undefined
|
|
350
|
+
if (!isInteractive) {
|
|
351
|
+
return detectedIDEs.length > 0 ? detectedIDEs : undefined;
|
|
352
|
+
}
|
|
353
|
+
console.log('\n--- IDE Configuration ---');
|
|
354
|
+
// Build checkbox choices for available IDEs
|
|
355
|
+
const availableIDEs = [IDEName.VSCODE]; // Only VSCode is currently supported
|
|
356
|
+
const choices = availableIDEs.map((ide) => ({
|
|
357
|
+
name: ide,
|
|
358
|
+
value: ide,
|
|
359
|
+
checked: detectedIDEs.includes(ide), // Pre-check detected IDEs
|
|
360
|
+
}));
|
|
361
|
+
const selected = await promptCheckbox('Select IDEs to configure:', choices);
|
|
362
|
+
return selected.length > 0 ? selected : undefined;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Detect IDE folders in the working directory.
|
|
366
|
+
*/
|
|
367
|
+
async detectIDEs(workingDir) {
|
|
368
|
+
const vscodeExists = await pathExists(path.join(workingDir, VSCODE_FOLDER));
|
|
369
|
+
const ideaExists = await pathExists(path.join(workingDir, IDEA_FOLDER));
|
|
370
|
+
const detectedIDEs = [];
|
|
371
|
+
if (vscodeExists) {
|
|
372
|
+
detectedIDEs.push(IDEName.VSCODE);
|
|
373
|
+
console.log(`Detected: VSCode (${VSCODE_FOLDER} folder found)`);
|
|
374
|
+
}
|
|
375
|
+
if (ideaExists) {
|
|
376
|
+
console.log(`Note: ${IDEA_FOLDER} folder found, but IntelliJ IDEA is not yet supported in this version.`);
|
|
377
|
+
}
|
|
378
|
+
return detectedIDEs;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { AbstractCommands } from './AbstractCommands.js';
|
|
2
|
+
import { promptConfirm } from '../lib/prompt.js';
|
|
3
|
+
export class MaintenanceCommands extends AbstractCommands {
|
|
4
|
+
/**
|
|
5
|
+
* (Re)Write agent template files in the .specs/.template/.forge-agents/ directory of the main repository.
|
|
6
|
+
* This is useful if you want to fix the agent's files in the project.
|
|
7
|
+
*/
|
|
8
|
+
async installAgentTemplateLocally(options = {}) {
|
|
9
|
+
const deleteExisting = Boolean(options.deleteExisting);
|
|
10
|
+
const dryRun = Boolean(options.dryRun);
|
|
11
|
+
const commit = options.commit === undefined ? undefined : Boolean(options.commit);
|
|
12
|
+
const overwrite = Boolean(options.overwrite);
|
|
13
|
+
console.log(`Install agent templates locally (deleteExisting=${deleteExisting}, dryRun=${dryRun}, commit=${commit}, overwrite=${overwrite})...`);
|
|
14
|
+
const changed = await this.context.installAgentTemplateLocally(this.context.mainRepo, { deleteExisting, dryRun, overwrite });
|
|
15
|
+
if (changed.length === 0) {
|
|
16
|
+
console.log('No agent template files were modified.');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
console.log(`Modified ${changed.length} file(s):`);
|
|
20
|
+
for (const f of changed) {
|
|
21
|
+
console.log(` - ${f}`);
|
|
22
|
+
}
|
|
23
|
+
if (dryRun) {
|
|
24
|
+
console.log('\nDry run: no files were written.');
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const doCommit = commit === undefined ? await promptConfirm('Do you want to commit the changes to the repository?') : commit;
|
|
28
|
+
if (doCommit) {
|
|
29
|
+
if (changed.length > 0 && !dryRun && doCommit) {
|
|
30
|
+
await this.context.mainRepo.commit(`chore: add feat-forge agent templates ${overwrite ? ' (overwriting existing templates)' : ''}`, changed);
|
|
31
|
+
}
|
|
32
|
+
console.log('\nFiles were written and committed.');
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
console.log('\nFiles were written but not committed (use --commit to commit).');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { BranchContext } from '../foundation/BranchContext.js';
|
|
2
|
+
import { AbstractCommands } from './AbstractCommands.js';
|
|
3
|
+
export class ModeCommands extends AbstractCommands {
|
|
4
|
+
// ============================================================================
|
|
5
|
+
// PUBLIC COMMAND METHODS
|
|
6
|
+
// ============================================================================
|
|
7
|
+
/**
|
|
8
|
+
* Set the current mode and refresh agent adapters for the active feature.
|
|
9
|
+
*/
|
|
10
|
+
async setMode(mode) {
|
|
11
|
+
const branchContext = await BranchContext.findNearestBranchContext(this.context);
|
|
12
|
+
await branchContext.setMode(mode);
|
|
13
|
+
await branchContext.refreshAgentContextFiles(mode);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Proxy } from '../foundation/Proxy.js';
|
|
2
|
+
import { AbstractCommands } from './AbstractCommands.js';
|
|
3
|
+
export class ProxyCommands extends AbstractCommands {
|
|
4
|
+
async start(options) {
|
|
5
|
+
if (options.port) {
|
|
6
|
+
console.warn(`⚠️ Specifying a custom port with --port may result in problems if services communicates with each other through the proxy port/url declared in the .envrc files.
|
|
7
|
+
It is recommended to set the desired port in the configuration file instead.`);
|
|
8
|
+
}
|
|
9
|
+
const proxy = new Proxy(this.context);
|
|
10
|
+
await proxy.start({
|
|
11
|
+
port: options.port ? parseInt(options.port, 10) : undefined,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { BranchContext } from '../foundation/BranchContext.js';
|
|
2
|
+
import { PortAllocator } from '../foundation/PortAllocator.js';
|
|
3
|
+
import { generateBranchServicesFile, getServiceOutputs, loadGeneratedServicesFile, scanReposServices, } from '../lib/services.js';
|
|
4
|
+
import { AbstractCommands } from './AbstractCommands.js';
|
|
5
|
+
import { EnvCommands } from './EnvCommands.js';
|
|
6
|
+
import { ForgeNotInActiveBranchError } from '../foundation/errors/index.js';
|
|
7
|
+
export class ServicesCommands extends AbstractCommands {
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// PUBLIC COMMAND METHODS
|
|
10
|
+
// ============================================================================
|
|
11
|
+
/**
|
|
12
|
+
* Scan repositories for services and generate configuration files
|
|
13
|
+
*/
|
|
14
|
+
async scan(branchName) {
|
|
15
|
+
// Load branch context (use current branch if not specified)
|
|
16
|
+
let branches = [];
|
|
17
|
+
try {
|
|
18
|
+
const branchContext = branchName
|
|
19
|
+
? await this.context.loadBranchContext(branchName)
|
|
20
|
+
: await BranchContext.findNearestBranchContext(this.context);
|
|
21
|
+
branches = [branchContext];
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
if (err instanceof ForgeNotInActiveBranchError) {
|
|
25
|
+
branches.push(...(await this.context.loadActiveBranchesContexts()));
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Load port allocator
|
|
32
|
+
const portAllocator = await PortAllocator.load(this.context);
|
|
33
|
+
for (const branchContext of branches) {
|
|
34
|
+
// Scan for services in all repositories
|
|
35
|
+
console.log('-----------------------------------------------------------------------');
|
|
36
|
+
console.log(`🔍 Scanning for services in branch "${branchContext.branchName}"...`);
|
|
37
|
+
const reposServices = await scanReposServices(branchContext);
|
|
38
|
+
if (reposServices.some((repo) => repo.services.length > 0)) {
|
|
39
|
+
console.log(`✅ Found services in ${reposServices.length} repository(ies)`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.log('⚠️ No services found in any repositories');
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
console.log('🔄 Allocating ports for discovered services...');
|
|
46
|
+
// Allocate ports for all repositories
|
|
47
|
+
portAllocator.allocatePorts(branchContext.branchName, reposServices);
|
|
48
|
+
// Save port allocations
|
|
49
|
+
await portAllocator.save();
|
|
50
|
+
console.log(' ✅ Port allocation completed');
|
|
51
|
+
console.log('');
|
|
52
|
+
// Generate and write generated.services.json
|
|
53
|
+
console.log('🔄 Generating generated.services.json with port assignments...');
|
|
54
|
+
const generated = await generateBranchServicesFile(branchContext, portAllocator);
|
|
55
|
+
console.log(` ✅ generated.services.json: ${generated.path}`);
|
|
56
|
+
console.log('');
|
|
57
|
+
// Generate and write .envrc
|
|
58
|
+
await new EnvCommands(this.context).generateEnvrcFile(branchContext, generated.services);
|
|
59
|
+
// Display summary
|
|
60
|
+
this.showServiceSummary(branchContext, generated.services);
|
|
61
|
+
console.log('');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* List discovered services with their allocated ports
|
|
66
|
+
*/
|
|
67
|
+
async list(format) {
|
|
68
|
+
// Load current branch context
|
|
69
|
+
const branchContext = await BranchContext.findNearestBranchContext(this.context);
|
|
70
|
+
// Load generated services file
|
|
71
|
+
const generatedServicesFile = await loadGeneratedServicesFile(branchContext);
|
|
72
|
+
if (format === 'json') {
|
|
73
|
+
// Output as JSON
|
|
74
|
+
console.log(JSON.stringify(generatedServicesFile, null, 2));
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
// Default log format
|
|
78
|
+
this.showServiceSummary(branchContext, generatedServicesFile.services);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// PRIVATE UTILITY METHODS
|
|
83
|
+
// ============================================================================
|
|
84
|
+
showServiceSummary(branchContext, services) {
|
|
85
|
+
console.log('Services Summary:');
|
|
86
|
+
for (const service of services) {
|
|
87
|
+
const { name, port, url, proxyUrl } = getServiceOutputs(this.context, branchContext, service);
|
|
88
|
+
console.log(` 🚀 ${name}: (PORT:${port})`);
|
|
89
|
+
console.log(` 🌐 url: ${url}`);
|
|
90
|
+
if (this.context.options.proxy.enabled) {
|
|
91
|
+
console.log(` 🔀 proxy-url: ${proxyUrl}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { AbstractCommands } from './AbstractCommands.js';
|
|
2
|
+
import { BranchCommands } from './BranchCommands.js';
|
|
3
|
+
export class SubBranchCommands extends AbstractCommands {
|
|
4
|
+
branchCommands = new BranchCommands(this.context);
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// PUBLIC COMMAND METHODS
|
|
7
|
+
// ============================================================================
|
|
8
|
+
async create(rawSlug) {
|
|
9
|
+
rawSlug = this.toSubBranchSlug(rawSlug);
|
|
10
|
+
return this.branchCommands.create(rawSlug);
|
|
11
|
+
}
|
|
12
|
+
async start(rawSlug) {
|
|
13
|
+
rawSlug = this.toSubBranchSlug(rawSlug);
|
|
14
|
+
return this.branchCommands.start(rawSlug);
|
|
15
|
+
}
|
|
16
|
+
async list() {
|
|
17
|
+
return this.branchCommands.list(this.prefix);
|
|
18
|
+
}
|
|
19
|
+
async resync(rawSlug) {
|
|
20
|
+
rawSlug = this.toSubBranchSlug(rawSlug);
|
|
21
|
+
return this.branchCommands.resync(rawSlug);
|
|
22
|
+
}
|
|
23
|
+
async stop(rawSlug) {
|
|
24
|
+
rawSlug = this.toSubBranchSlug(rawSlug);
|
|
25
|
+
return this.branchCommands.stop(rawSlug);
|
|
26
|
+
}
|
|
27
|
+
async archive(rawSlug) {
|
|
28
|
+
rawSlug = this.toSubBranchSlug(rawSlug);
|
|
29
|
+
return this.branchCommands.archive(rawSlug);
|
|
30
|
+
}
|
|
31
|
+
async open(rawSlug) {
|
|
32
|
+
rawSlug = rawSlug ? this.toSubBranchSlug(rawSlug) : undefined;
|
|
33
|
+
return this.branchCommands.open(rawSlug);
|
|
34
|
+
}
|
|
35
|
+
async merge(rawSlug) {
|
|
36
|
+
rawSlug = this.toSubBranchSlug(rawSlug);
|
|
37
|
+
return this.branchCommands.merge(rawSlug);
|
|
38
|
+
}
|
|
39
|
+
async rebase(rawSlug) {
|
|
40
|
+
rawSlug = this.toSubBranchSlug(rawSlug);
|
|
41
|
+
return this.branchCommands.rebase(rawSlug);
|
|
42
|
+
}
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// PRIVATE METHODS
|
|
45
|
+
// ============================================================================
|
|
46
|
+
toSubBranchSlug(rawSlug) {
|
|
47
|
+
return `${this.prefix}${rawSlug}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|