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
package/dist/lib/git.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
import { pathExists } from './fs.js';
|
|
4
|
+
export var GitOperation;
|
|
5
|
+
(function (GitOperation) {
|
|
6
|
+
GitOperation["Merge"] = "merge";
|
|
7
|
+
GitOperation["Rebase"] = "rebase";
|
|
8
|
+
})(GitOperation || (GitOperation = {}));
|
|
9
|
+
export async function findGitRoot(startDir = process.cwd()) {
|
|
10
|
+
let current = path.resolve(startDir);
|
|
11
|
+
while (true) {
|
|
12
|
+
const gitPath = path.join(current, '.git');
|
|
13
|
+
if (await pathExists(gitPath)) {
|
|
14
|
+
return current;
|
|
15
|
+
}
|
|
16
|
+
const parent = path.dirname(current);
|
|
17
|
+
if (parent === current) {
|
|
18
|
+
throw new Error('Not inside a git repository (no .git directory found).');
|
|
19
|
+
}
|
|
20
|
+
current = parent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Run a git command inside the given repo root.
|
|
25
|
+
*/
|
|
26
|
+
export async function runGit(repoRoot, args) {
|
|
27
|
+
await execa('git', args, { cwd: repoRoot, stdio: 'inherit' });
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Return git status porcelain output for the given working directory.
|
|
31
|
+
*/
|
|
32
|
+
export async function getGitStatusPorcelain(cwd) {
|
|
33
|
+
const result = await execa('git', ['status', '--porcelain'], { cwd });
|
|
34
|
+
return result.stdout.trim();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Return true if a branch exists.
|
|
38
|
+
*/
|
|
39
|
+
export async function gitBranchExists(repoRoot, branchName) {
|
|
40
|
+
try {
|
|
41
|
+
await execa('git', ['rev-parse', '--verify', branchName], { cwd: repoRoot });
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Return true if a path exists in the given branch.
|
|
50
|
+
*/
|
|
51
|
+
export async function gitPathExistsInBranch(repoRoot, branchName, targetPath) {
|
|
52
|
+
try {
|
|
53
|
+
await execa('git', ['cat-file', '-e', `${branchName}:${targetPath}`], { cwd: repoRoot });
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get the current branch name for a repo.
|
|
62
|
+
*/
|
|
63
|
+
export async function getCurrentBranch(repoRoot) {
|
|
64
|
+
try {
|
|
65
|
+
const result = await execa('git', ['branch', '--show-current'], { cwd: repoRoot });
|
|
66
|
+
return result.stdout.trim() || null;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Checkout a branch in a repo.
|
|
74
|
+
*/
|
|
75
|
+
export async function checkoutBranch(repoRoot, branchName) {
|
|
76
|
+
await execa('git', ['checkout', branchName], { cwd: repoRoot, stdio: 'inherit' });
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Create a new branch in a repo.
|
|
80
|
+
*/
|
|
81
|
+
export async function createBranch(repoRoot, branchName, baseBranch) {
|
|
82
|
+
const args = ['branch', branchName];
|
|
83
|
+
if (baseBranch) {
|
|
84
|
+
args.push(baseBranch);
|
|
85
|
+
}
|
|
86
|
+
await runGit(repoRoot, args);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Get all branches in a repo.
|
|
90
|
+
*/
|
|
91
|
+
export async function getBranches(repoRoot, prefix) {
|
|
92
|
+
const args = ['branch', '--format=%(refname:short)'];
|
|
93
|
+
if (prefix) {
|
|
94
|
+
args.push('--list', `${prefix}*`);
|
|
95
|
+
}
|
|
96
|
+
const result = await execa('git', args, { cwd: repoRoot });
|
|
97
|
+
return result.stdout.split('\n').filter((b) => b.trim().length > 0);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Get all worktrees for a repo with their paths and branches.
|
|
101
|
+
*/
|
|
102
|
+
export async function getGitWorktrees(repoRoot) {
|
|
103
|
+
try {
|
|
104
|
+
const result = await execa('git', ['worktree', 'list', '--porcelain'], { cwd: repoRoot });
|
|
105
|
+
const lines = result.stdout.split('\n');
|
|
106
|
+
const worktrees = [];
|
|
107
|
+
let currentPath = '';
|
|
108
|
+
let currentBranch = '';
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
if (line.startsWith('worktree ')) {
|
|
111
|
+
currentPath = line.substring('worktree '.length);
|
|
112
|
+
}
|
|
113
|
+
else if (line.startsWith('branch ')) {
|
|
114
|
+
currentBranch = line.substring('branch '.length).replace(/^refs\/heads\//, '');
|
|
115
|
+
}
|
|
116
|
+
else if (line === '' && currentPath) {
|
|
117
|
+
if (currentBranch) {
|
|
118
|
+
worktrees.push({ path: currentPath, branch: currentBranch });
|
|
119
|
+
}
|
|
120
|
+
currentPath = '';
|
|
121
|
+
currentBranch = '';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Handle last entry if file doesn't end with blank line
|
|
125
|
+
if (currentPath && currentBranch) {
|
|
126
|
+
worktrees.push({ path: currentPath, branch: currentBranch });
|
|
127
|
+
}
|
|
128
|
+
return worktrees;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw new Error(`Failed to get worktrees for repo at ${repoRoot}. Ensure it's a valid git repository and that git is installed.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Display summary of git operation results.
|
|
136
|
+
*
|
|
137
|
+
* @param results - Array of operation results
|
|
138
|
+
* @param operationType - Name of the operation (e.g., "merge", "rebase")
|
|
139
|
+
*/
|
|
140
|
+
export function displayOperationSummary(results, operationType) {
|
|
141
|
+
console.log(`\n=== ${operationType.charAt(0).toUpperCase() + operationType.slice(1)} Summary ===`);
|
|
142
|
+
const successful = results.filter((r) => r.success);
|
|
143
|
+
const conflicts = results.filter((r) => r.hasConflicts);
|
|
144
|
+
const failed = results.filter((r) => !r.success && !r.hasConflicts);
|
|
145
|
+
if (successful.length > 0) {
|
|
146
|
+
console.log(`\n✅ Successful ${operationType}s (${successful.length}):`);
|
|
147
|
+
successful.forEach((r) => console.log(` - ${r.repo}`));
|
|
148
|
+
}
|
|
149
|
+
if (conflicts.length > 0) {
|
|
150
|
+
console.log(`\n⚠️ Conflicts to resolve (${conflicts.length}):`);
|
|
151
|
+
conflicts.forEach((r) => console.log(` - ${r.repo}`));
|
|
152
|
+
}
|
|
153
|
+
if (failed.length > 0) {
|
|
154
|
+
console.log(`\n❌ Failed ${operationType}s (${failed.length}):`);
|
|
155
|
+
failed.forEach((r) => console.log(` - ${r.repo}`));
|
|
156
|
+
}
|
|
157
|
+
// Summary message
|
|
158
|
+
if (conflicts.length === 0 && failed.length === 0) {
|
|
159
|
+
console.log(`\n🎉 All ${operationType}s completed successfully!`);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
console.log(`\n⚠️ Some ${operationType}s need attention. Please resolve conflicts or errors before continuing.`);
|
|
163
|
+
console.log(`After resolving conflicts, you can continue with: git ${operationType} --continue`);
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
successful,
|
|
167
|
+
conflicts,
|
|
168
|
+
failed,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { readdir } from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { pathExists } from './fs.js';
|
|
4
|
+
import { getScriptExtension } from './platform.js';
|
|
5
|
+
import { executeScript } from './bootstrap.js';
|
|
6
|
+
/**
|
|
7
|
+
* Available hook event types that can be triggered throughout the forge lifecycle.
|
|
8
|
+
* Each event can have multiple hooks (e.g., postStart.sh, postStart_01.sh).
|
|
9
|
+
*/
|
|
10
|
+
export var HookEvent;
|
|
11
|
+
(function (HookEvent) {
|
|
12
|
+
/** Triggered after a branch is started */
|
|
13
|
+
HookEvent["POST_START"] = "postStart";
|
|
14
|
+
/** Triggered before a merge operation */
|
|
15
|
+
HookEvent["PRE_MERGE"] = "preMerge";
|
|
16
|
+
/** Triggered after a merge operation */
|
|
17
|
+
HookEvent["POST_MERGE"] = "postMerge";
|
|
18
|
+
/** Triggered before stopping a branch */
|
|
19
|
+
HookEvent["PRE_STOP"] = "preStop";
|
|
20
|
+
/** Triggered before a rebase operation */
|
|
21
|
+
HookEvent["PRE_REBASE"] = "preRebase";
|
|
22
|
+
/** Triggered after a rebase operation */
|
|
23
|
+
HookEvent["POST_REBASE"] = "postRebase";
|
|
24
|
+
/** Triggered before deleting a branch */
|
|
25
|
+
HookEvent["PRE_DELETE"] = "preDelete";
|
|
26
|
+
/** Triggered before archiving a branch */
|
|
27
|
+
HookEvent["PRE_ARCHIVE"] = "preArchive";
|
|
28
|
+
/** Triggered after refreshing agent context files */
|
|
29
|
+
HookEvent["POST_REFRESH_AGENTS"] = "postRefreshAgents";
|
|
30
|
+
/** Triggered after setting active specs */
|
|
31
|
+
HookEvent["POST_SET_ACTIVE_SPECS"] = "postSetActiveSpecs";
|
|
32
|
+
})(HookEvent || (HookEvent = {}));
|
|
33
|
+
/**
|
|
34
|
+
* Discover available hooks for a specific event type.
|
|
35
|
+
* Returns hook names matching the event type without extension (e.g., 'postStart' from 'postStart.sh').
|
|
36
|
+
* Supports multiple hooks for the same event (e.g., postStart, postStart_01, postStart_02).
|
|
37
|
+
* Hooks are returned in alphabetical order for predictable execution.
|
|
38
|
+
*
|
|
39
|
+
* @param repositoryPath - Path to the repository/worktree
|
|
40
|
+
* @param repoConfigFolderPath - Relative path to the repo config folder (e.g., '.forge')
|
|
41
|
+
* @param eventType - Type of event (HookEvent enum value)
|
|
42
|
+
* @returns Array of hook names in alphabetical order
|
|
43
|
+
*/
|
|
44
|
+
export async function discoverHooksForEvent(repositoryPath, repoConfigFolderPath, eventType) {
|
|
45
|
+
const hooksDir = path.join(repositoryPath, repoConfigFolderPath, 'hooks');
|
|
46
|
+
if (!(await pathExists(hooksDir))) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const files = await readdir(hooksDir);
|
|
51
|
+
const scriptExtension = getScriptExtension();
|
|
52
|
+
const hookNames = files
|
|
53
|
+
.filter((file) => {
|
|
54
|
+
if (!file.endsWith(scriptExtension)) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const hookName = file.slice(0, -scriptExtension.length);
|
|
58
|
+
// Match exact event type or event type with suffix (e.g., postStart, postStart_01)
|
|
59
|
+
return hookName === eventType || hookName.startsWith(`${eventType}_`);
|
|
60
|
+
})
|
|
61
|
+
.map((file) => file.slice(0, -scriptExtension.length))
|
|
62
|
+
.sort(); // Sort alphabetically for predictable order
|
|
63
|
+
return hookNames;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Execute all hooks for a specific event type.
|
|
71
|
+
* Hooks are executed in alphabetical order for predictable, consistent execution.
|
|
72
|
+
*
|
|
73
|
+
* @param repositoryPath - Path to the repository/worktree
|
|
74
|
+
* @param repoConfigFolderPath - Relative path to the repo config folder (e.g., '.forge')
|
|
75
|
+
* @param eventType - Type of event (HookEvent enum value)
|
|
76
|
+
* @param params - Optional parameters to pass to hooks as environment variables (FORGE_HOOK_PARAM_NAME)
|
|
77
|
+
* @returns Array of hook names that were executed
|
|
78
|
+
* @throws Error if any hook fails
|
|
79
|
+
*/
|
|
80
|
+
export async function executeHooksForEvent(repositoryPath, repoConfigFolderPath, eventType, params) {
|
|
81
|
+
const hookNames = await discoverHooksForEvent(repositoryPath, repoConfigFolderPath, eventType);
|
|
82
|
+
if (hookNames.length === 0) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const scriptExtension = getScriptExtension();
|
|
86
|
+
const executedHooks = [];
|
|
87
|
+
for (const hookName of hookNames) {
|
|
88
|
+
const scriptPath = path.join(repositoryPath, repoConfigFolderPath, 'hooks', `${hookName}${scriptExtension}`);
|
|
89
|
+
try {
|
|
90
|
+
await executeScript(scriptPath, repositoryPath, `hook: ${hookName}`, params);
|
|
91
|
+
executedHooks.push(hookName);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
throw new Error(`Hook ${hookName} failed in ${repositoryPath}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return executedHooks;
|
|
98
|
+
}
|
package/dist/lib/ide.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { writeFile } from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { pathExists } from './fs.js';
|
|
4
|
+
import { IDEName } from '../foundation/types/IDEName.js';
|
|
5
|
+
import { slugify } from './slug.js';
|
|
6
|
+
/**
|
|
7
|
+
* Get the default CLI command for an IDE
|
|
8
|
+
* @param ideName - The IDE name
|
|
9
|
+
* @returns The default CLI command for the IDE
|
|
10
|
+
*/
|
|
11
|
+
export function getDefaultIDECommand(ideName) {
|
|
12
|
+
const defaultCommands = {
|
|
13
|
+
[IDEName.VSCODE]: 'code',
|
|
14
|
+
};
|
|
15
|
+
return defaultCommands[ideName];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create IDE workspace files for a feature
|
|
19
|
+
*/
|
|
20
|
+
export async function createIDEWorkspaces(branchName, branchRoot, mainRepoName, repositories, ides, agents) {
|
|
21
|
+
for (const ide of ides) {
|
|
22
|
+
if (!ide.createWorkspace) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
switch (ide.name) {
|
|
26
|
+
case IDEName.VSCODE:
|
|
27
|
+
await createVSCodeWorkspaceFile(branchName, branchRoot, ide, mainRepoName, repositories, agents);
|
|
28
|
+
break;
|
|
29
|
+
default:
|
|
30
|
+
console.warn(`Unknown IDE: ${ide.name}, skipping workspace creation`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function getWorkspaceFileName(branchName, ideName) {
|
|
35
|
+
// FIXME: must handle IDE specific file extensions (.code-workspace for VSCode, .cw for Cursor, .wsp for Windsurf, etc.)
|
|
36
|
+
return `${slugify(branchName, false)}.code-workspace`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Create a VSCode-style workspace file (.code-workspace)
|
|
40
|
+
* Also works for Cursor and Windsurf which use the same format
|
|
41
|
+
*/
|
|
42
|
+
async function createVSCodeWorkspaceFile(branchName, branchRoot, ide, mainRepoName, repositories, agents) {
|
|
43
|
+
const workspaceFileName = await getWorkspaceFileName(branchName, ide.name);
|
|
44
|
+
const workspaceFilePath = path.join(branchRoot, workspaceFileName);
|
|
45
|
+
// Check if workspace already exists
|
|
46
|
+
if (await pathExists(workspaceFilePath)) {
|
|
47
|
+
console.log(`Workspace file already exists: ${workspaceFileName}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Build workspace settings
|
|
51
|
+
const settings = { ...ide.settings };
|
|
52
|
+
const workspace = {
|
|
53
|
+
// folders: repositories.map((repo) => ({ path: `./${repo.name}` })),
|
|
54
|
+
folders: [{ path: '.' }], // allow visibility on .xxx folders, used by many agents
|
|
55
|
+
settings,
|
|
56
|
+
};
|
|
57
|
+
await writeFile(workspaceFilePath, JSON.stringify(workspace, null, 2), 'utf8');
|
|
58
|
+
console.log(`Created ${ide.name} workspace: ${workspaceFileName}`);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Update IDE settings in an existing workspace
|
|
62
|
+
*/
|
|
63
|
+
export async function updateIDEWorkspace(workspaceFilePath, ideName, newSettings) {
|
|
64
|
+
if (!(await pathExists(workspaceFilePath))) {
|
|
65
|
+
throw new Error(`Workspace file not found: ${workspaceFilePath}`);
|
|
66
|
+
}
|
|
67
|
+
const content = await import('fs/promises').then((m) => m.readFile(workspaceFilePath, 'utf8'));
|
|
68
|
+
const workspace = JSON.parse(content);
|
|
69
|
+
workspace.settings = {
|
|
70
|
+
...workspace.settings,
|
|
71
|
+
...newSettings,
|
|
72
|
+
};
|
|
73
|
+
await writeFile(workspaceFilePath, JSON.stringify(workspace, null, 2), 'utf8');
|
|
74
|
+
console.log(`Updated ${ideName} workspace: ${path.basename(workspaceFilePath)}`);
|
|
75
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
function isObject(item) {
|
|
2
|
+
return item && typeof item === 'object' && item !== null;
|
|
3
|
+
}
|
|
4
|
+
function isArray(item) {
|
|
5
|
+
return Array.isArray(item);
|
|
6
|
+
}
|
|
7
|
+
export class MergerOptions {
|
|
8
|
+
/**
|
|
9
|
+
* If true, arrays will be merged instead of replaced
|
|
10
|
+
* @default false
|
|
11
|
+
*/
|
|
12
|
+
mergeArrays = false;
|
|
13
|
+
/**
|
|
14
|
+
* If true, undefined values in sources will be merged and overwrite existing values in target.
|
|
15
|
+
* If false, undefined values will be ignored.
|
|
16
|
+
*/
|
|
17
|
+
mergeUndefined = true;
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
Object.assign(this, options);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Class that provides a merge function to deep merge objects
|
|
24
|
+
* with options
|
|
25
|
+
*/
|
|
26
|
+
export class Merger {
|
|
27
|
+
options;
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
this.options = options;
|
|
30
|
+
this.options = Object.assign(new MergerOptions(), options);
|
|
31
|
+
}
|
|
32
|
+
static build(options = {}) {
|
|
33
|
+
return new Merger(options);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Like Object.assign but will do a deep merge
|
|
37
|
+
* @param target where all sources will be merged
|
|
38
|
+
* @param sources what to merge into target, one or multiple
|
|
39
|
+
* @returns
|
|
40
|
+
*/
|
|
41
|
+
merge(target, ...sources) {
|
|
42
|
+
if (isObject(target)) {
|
|
43
|
+
let sLen = sources.length;
|
|
44
|
+
for (let i = 0; i < sLen; i++) {
|
|
45
|
+
const source = sources[i];
|
|
46
|
+
if (isObject(source)) {
|
|
47
|
+
for (const key in source) {
|
|
48
|
+
if (source.hasOwnProperty(key)) {
|
|
49
|
+
if (source[key] instanceof Date) {
|
|
50
|
+
target[key] = new Date(source[key].getTime());
|
|
51
|
+
}
|
|
52
|
+
else if (isArray(source[key])) {
|
|
53
|
+
if (this.options.mergeArrays) {
|
|
54
|
+
target[key] = (target[key] || []).concat(source[key]);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
target[key] = source[key].slice();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (isObject(source[key])) {
|
|
61
|
+
if (!target[key]) {
|
|
62
|
+
target[key] = {};
|
|
63
|
+
}
|
|
64
|
+
this.merge(target[key], source[key]);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
if (source[key] === undefined && !this.options.mergeUndefined) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
Object.assign(target, { [key]: source[key] });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return target;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const defaultMerge = Merger.build();
|
|
81
|
+
const arrayMerger = Merger.build({ mergeArrays: true });
|
|
82
|
+
const mergeDropUndefinedMerger = Merger.build({ mergeUndefined: false });
|
|
83
|
+
/**
|
|
84
|
+
* Like Object.assign but will do a deep merge
|
|
85
|
+
* @param target where all sources will be merged
|
|
86
|
+
* @param sources what to merge into target, one or multiple
|
|
87
|
+
* @returns
|
|
88
|
+
*/
|
|
89
|
+
export const merge = defaultMerge.merge.bind(defaultMerge);
|
|
90
|
+
/**
|
|
91
|
+
* Like Object.assign but will do a deep merge and will concat arrays instead of replacing them
|
|
92
|
+
* @param target where all sources will be merged
|
|
93
|
+
* @param sources what to merge into target, one or multiple
|
|
94
|
+
* @returns
|
|
95
|
+
*/
|
|
96
|
+
export const mergeConcatArrays = arrayMerger.merge.bind(arrayMerger);
|
|
97
|
+
/**
|
|
98
|
+
* Like Object.assign but will do a deep merge and will ignore undefined values in sources
|
|
99
|
+
* @param target where all sources will be merged
|
|
100
|
+
* @param sources what to merge into target, one or multiple
|
|
101
|
+
* @returns
|
|
102
|
+
*/
|
|
103
|
+
export const mergeDropUndefined = mergeDropUndefinedMerger.merge.bind(mergeDropUndefinedMerger);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if the current platform is Windows
|
|
3
|
+
*/
|
|
4
|
+
export function isWindows() {
|
|
5
|
+
return process.platform === 'win32';
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Get the script extension based on the current platform
|
|
9
|
+
* @returns '.bat' on Windows, '.sh' on other platforms
|
|
10
|
+
*/
|
|
11
|
+
export function getScriptExtension() {
|
|
12
|
+
return isWindows() ? '.bat' : '.sh';
|
|
13
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { select, input, confirm, checkbox } from '@inquirer/prompts';
|
|
2
|
+
import { getBranches } from './git.js';
|
|
3
|
+
/**
|
|
4
|
+
* Prompt the user to pick a single choice by key.
|
|
5
|
+
*/
|
|
6
|
+
export async function promptChoice(message, choices) {
|
|
7
|
+
return await select({
|
|
8
|
+
message,
|
|
9
|
+
choices,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Prompt the user for free-form input.
|
|
14
|
+
*/
|
|
15
|
+
export async function promptText(message) {
|
|
16
|
+
return await input({
|
|
17
|
+
message,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Prompt the user for a yes/no confirmation.
|
|
22
|
+
*/
|
|
23
|
+
export async function promptConfirm(message) {
|
|
24
|
+
return await confirm({
|
|
25
|
+
message,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Prompt the user to select multiple items using checkboxes.
|
|
30
|
+
*/
|
|
31
|
+
export async function promptCheckbox(message, choices) {
|
|
32
|
+
return await checkbox({
|
|
33
|
+
message,
|
|
34
|
+
choices: choices.map((choice) => ({
|
|
35
|
+
name: choice.name,
|
|
36
|
+
value: choice.value,
|
|
37
|
+
checked: choice.checked || false,
|
|
38
|
+
})),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Prompt user to select a branch from available local branches.
|
|
43
|
+
*
|
|
44
|
+
* Displays common branches (main, master, dev, develop, trunk) first,
|
|
45
|
+
* followed by feature branches (optional) and other branches,
|
|
46
|
+
* and allows manual entry via "Other" option.
|
|
47
|
+
*
|
|
48
|
+
* @param repoRoot - The repository root to query for branches
|
|
49
|
+
* @param message - Custom message for the prompt
|
|
50
|
+
* @param includeFeatureBranches - Whether to include feature/ branches in the list
|
|
51
|
+
* @returns The selected branch name
|
|
52
|
+
* @throws Error if branch name is empty or selection is invalid
|
|
53
|
+
*/
|
|
54
|
+
export async function promptForBranch(repoRoot, message, featureBranchPrefix, includeFeatureBranches = false) {
|
|
55
|
+
// Get all local branches
|
|
56
|
+
const allBranches = await getBranches(repoRoot);
|
|
57
|
+
// Common branches to prioritize in the selection menu
|
|
58
|
+
const commonBranches = ['main', 'master', 'dev', 'develop', 'trunk'];
|
|
59
|
+
// Separate branches by type
|
|
60
|
+
const priorityBranches = commonBranches.filter((b) => allBranches.includes(b));
|
|
61
|
+
const featureBranches = allBranches.filter((b) => b.startsWith(featureBranchPrefix));
|
|
62
|
+
const otherBranches = allBranches.filter((b) => !commonBranches.includes(b) && !b.startsWith(featureBranchPrefix));
|
|
63
|
+
// Build choices menu
|
|
64
|
+
const choices = [];
|
|
65
|
+
// Add priority branches first
|
|
66
|
+
for (const branch of priorityBranches) {
|
|
67
|
+
choices.push({ name: branch, value: branch });
|
|
68
|
+
}
|
|
69
|
+
// Add feature branches if requested
|
|
70
|
+
if (includeFeatureBranches && featureBranches.length > 0) {
|
|
71
|
+
for (const branch of featureBranches) {
|
|
72
|
+
choices.push({ name: branch, value: branch });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Add other branches
|
|
76
|
+
for (const branch of otherBranches) {
|
|
77
|
+
choices.push({ name: branch, value: branch });
|
|
78
|
+
}
|
|
79
|
+
// Add manual entry option
|
|
80
|
+
choices.push({ name: 'Other (enter branch name)', value: '__other__' });
|
|
81
|
+
let branchName = null;
|
|
82
|
+
// Expect a choice to be made until a valid branch name is obtained
|
|
83
|
+
while (true) {
|
|
84
|
+
const selection = await promptChoice(message, choices);
|
|
85
|
+
// Handle manual entry
|
|
86
|
+
if (selection === '__other__') {
|
|
87
|
+
const branchNameInput = await promptText('Enter branch name:');
|
|
88
|
+
if (branchNameInput) {
|
|
89
|
+
branchName = branchNameInput;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
branchName = selection;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return branchName;
|
|
99
|
+
}
|
|
100
|
+
export var DirtyAction;
|
|
101
|
+
(function (DirtyAction) {
|
|
102
|
+
DirtyAction["Commit"] = "A";
|
|
103
|
+
DirtyAction["Cancel"] = "B";
|
|
104
|
+
DirtyAction["Discard"] = "C";
|
|
105
|
+
})(DirtyAction || (DirtyAction = {}));
|
|
106
|
+
function isDirtyAction(value) {
|
|
107
|
+
return value === DirtyAction.Commit || value === DirtyAction.Cancel || value === DirtyAction.Discard;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Prompt user to select an action for dirty worktrees.
|
|
111
|
+
*
|
|
112
|
+
* Presents options to commit changes, discard changes, or stop the operation.
|
|
113
|
+
* Returns the user's choice as 'A', 'B', or 'C'.
|
|
114
|
+
*/
|
|
115
|
+
export async function promptDirtyActions() {
|
|
116
|
+
while (true) {
|
|
117
|
+
const answer = await promptChoice('Worktree contain uncommitted changes. Choose an action:', [
|
|
118
|
+
{ value: DirtyAction.Commit, name: 'Commit work (you will be asked for a commit message)' },
|
|
119
|
+
{ value: DirtyAction.Cancel, name: 'Stop here and do nothing' },
|
|
120
|
+
{ value: DirtyAction.Discard, name: 'Discard work and remove worktrees' },
|
|
121
|
+
]);
|
|
122
|
+
if (isDirtyAction(answer)) {
|
|
123
|
+
if (answer === DirtyAction.Commit) {
|
|
124
|
+
const commitMessage = await promptText('Enter commit message:');
|
|
125
|
+
if (!commitMessage) {
|
|
126
|
+
console.log('Commit message is required.');
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
return { action: DirtyAction.Commit, commitMessage };
|
|
130
|
+
}
|
|
131
|
+
return { action: answer };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
async function checkHealth(target) {
|
|
3
|
+
return new Promise((resolve) => {
|
|
4
|
+
const req = http.get(target, { timeout: 2000 }, (res) => {
|
|
5
|
+
res.resume();
|
|
6
|
+
resolve(true);
|
|
7
|
+
});
|
|
8
|
+
req.on('error', () => resolve(false));
|
|
9
|
+
req.on('timeout', () => {
|
|
10
|
+
req.destroy();
|
|
11
|
+
resolve(false);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export async function handleDashboardRequest(req, res, routingTable, proxyPort) {
|
|
16
|
+
const statuses = [];
|
|
17
|
+
const checks = Array.from(routingTable.entries()).map(async ([key, route]) => {
|
|
18
|
+
const healthy = await checkHealth(route.url);
|
|
19
|
+
statuses.push({ key, route, healthy });
|
|
20
|
+
});
|
|
21
|
+
await Promise.all(checks);
|
|
22
|
+
statuses.sort((a, b) => a.route.branchName.localeCompare(b.route.branchName) || a.route.serviceName.localeCompare(b.route.serviceName));
|
|
23
|
+
const grouped = new Map();
|
|
24
|
+
for (const s of statuses) {
|
|
25
|
+
const list = grouped.get(s.route.branchName) ?? [];
|
|
26
|
+
list.push(s);
|
|
27
|
+
grouped.set(s.route.branchName, list);
|
|
28
|
+
}
|
|
29
|
+
const rows = Array.from(grouped.entries())
|
|
30
|
+
.map(([branchName, services]) => {
|
|
31
|
+
const header = `<tr class="branch-header"><td colspan="4"><strong>${branchName}</strong></td></tr>`;
|
|
32
|
+
const serviceRows = services
|
|
33
|
+
.map((s) => {
|
|
34
|
+
const statusBadge = s.healthy
|
|
35
|
+
? '<span style="color:#22c55e;font-weight:bold">● UP</span>'
|
|
36
|
+
: '<span style="color:#ef4444;font-weight:bold">● DOWN</span>';
|
|
37
|
+
return `<tr>
|
|
38
|
+
<td>${s.route.serviceName}</td>
|
|
39
|
+
<td><a href="${s.route.proxyUrl}" target="_blank">${s.route.proxyUrl}</a></td>
|
|
40
|
+
<td><a href="${s.route.url}" target="_blank">${s.route.url}</a></td>
|
|
41
|
+
<td>${statusBadge}</td>
|
|
42
|
+
</tr>`;
|
|
43
|
+
})
|
|
44
|
+
.join('\n');
|
|
45
|
+
return header + '\n' + serviceRows;
|
|
46
|
+
})
|
|
47
|
+
.join('\n');
|
|
48
|
+
const html = `<!DOCTYPE html>
|
|
49
|
+
<html><head>
|
|
50
|
+
<meta charset="utf-8">
|
|
51
|
+
<meta http-equiv="refresh" content="30">
|
|
52
|
+
<title>Feat-Forge Proxy Dashboard</title>
|
|
53
|
+
<style>
|
|
54
|
+
body { font-family: system-ui, sans-serif; margin: 2rem; background: #0f172a; color: #e2e8f0; }
|
|
55
|
+
h1 { color: #38bdf8; }
|
|
56
|
+
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
|
|
57
|
+
th, td { padding: 0.6rem 1rem; text-align: left; border-bottom: 1px solid #334155; }
|
|
58
|
+
th { background: #1e293b; color: #94a3b8; font-size: 0.85rem; text-transform: uppercase; }
|
|
59
|
+
tr:hover { background: #1e293b; }
|
|
60
|
+
.branch-header td { background: #1e293b; border-bottom: 2px solid #475569; padding-top: 1rem; color: #f1f5f9; font-size: 1rem; }
|
|
61
|
+
a { color: #38bdf8; text-decoration: none; }
|
|
62
|
+
a:hover { text-decoration: underline; }
|
|
63
|
+
.meta { color: #64748b; font-size: 0.85rem; margin-top: 0.5rem; }
|
|
64
|
+
</style>
|
|
65
|
+
</head><body>
|
|
66
|
+
<h1>Feat-Forge Proxy Dashboard</h1>
|
|
67
|
+
<p class="meta">${statuses.length} routes · auto-refresh 30s · proxy port ${proxyPort}</p>
|
|
68
|
+
<table>
|
|
69
|
+
<thead><tr><th>Service</th><th>Proxy URL</th><th>Target</th><th>Status</th></tr></thead>
|
|
70
|
+
<tbody>${rows || '<tr><td colspan="4" style="text-align:center;color:#64748b">No routes configured. Run <code>forge services scan</code> on active branches.</td></tr>'}</tbody>
|
|
71
|
+
</table>
|
|
72
|
+
</body></html>`;
|
|
73
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
74
|
+
res.end(html);
|
|
75
|
+
}
|