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,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
+ }
@@ -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 &middot; auto-refresh 30s &middot; 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
+ }