@mrpatronz/nexusflow 0.2.12 → 0.2.13

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 (88) hide show
  1. package/dist/commands/add-repo.d.ts.map +1 -1
  2. package/dist/commands/add-repo.js +24 -14
  3. package/dist/commands/add-repo.js.map +1 -1
  4. package/dist/commands/commit.js +1 -1
  5. package/dist/commands/commit.js.map +1 -1
  6. package/dist/commands/diff.js +1 -1
  7. package/dist/commands/diff.js.map +1 -1
  8. package/dist/commands/doctor.js +1 -1
  9. package/dist/commands/doctor.js.map +1 -1
  10. package/dist/commands/handoff.js +1 -1
  11. package/dist/commands/handoff.js.map +1 -1
  12. package/dist/commands/logs.js +1 -1
  13. package/dist/commands/logs.js.map +1 -1
  14. package/dist/commands/open.d.ts.map +1 -1
  15. package/dist/commands/open.js +12 -7
  16. package/dist/commands/open.js.map +1 -1
  17. package/dist/commands/pack.js +1 -1
  18. package/dist/commands/pack.js.map +1 -1
  19. package/dist/commands/refresh.js +13 -8
  20. package/dist/commands/refresh.js.map +1 -1
  21. package/dist/commands/remove.d.ts.map +1 -1
  22. package/dist/commands/remove.js +12 -7
  23. package/dist/commands/remove.js.map +1 -1
  24. package/dist/commands/start.js +1 -1
  25. package/dist/commands/start.js.map +1 -1
  26. package/dist/commands/status.js +1 -1
  27. package/dist/commands/status.js.map +1 -1
  28. package/dist/commands/stop.js +1 -1
  29. package/dist/commands/stop.js.map +1 -1
  30. package/dist/commands/sync.js +13 -8
  31. package/dist/commands/sync.js.map +1 -1
  32. package/dist/core/workspace.d.ts +7 -0
  33. package/dist/core/workspace.d.ts.map +1 -1
  34. package/dist/core/workspace.js +45 -1
  35. package/dist/core/workspace.js.map +1 -1
  36. package/dist/generators/index.d.ts.map +1 -1
  37. package/dist/generators/index.js +14 -0
  38. package/dist/generators/index.js.map +1 -1
  39. package/dist/gui/assets/index-CFWwqFux.js +23 -0
  40. package/dist/gui/assets/index-Dvc9QMvl.css +2 -0
  41. package/dist/gui/index.html +2 -2
  42. package/dist/orchestration/runner.d.ts +5 -6
  43. package/dist/orchestration/runner.d.ts.map +1 -1
  44. package/dist/orchestration/runner.js +105 -102
  45. package/dist/orchestration/runner.js.map +1 -1
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +53 -1
  48. package/dist/server.js.map +1 -1
  49. package/dist/utils/detect-editors.js +1 -1
  50. package/dist/utils/detect-editors.js.map +1 -1
  51. package/dist/utils/detect-editors.test.js +3 -3
  52. package/dist/utils/detect-editors.test.js.map +1 -1
  53. package/dist/utils/prompts.d.ts +1 -0
  54. package/dist/utils/prompts.d.ts.map +1 -1
  55. package/dist/utils/prompts.js +77 -15
  56. package/dist/utils/prompts.js.map +1 -1
  57. package/extension/package.json +9 -0
  58. package/extension/src/extension.ts +37 -1
  59. package/gui/eslint.config.js +4 -0
  60. package/gui/src/App.tsx +375 -151
  61. package/gui/src/features/changes/ChangesViewer.tsx +246 -70
  62. package/gui/src/features/services/ServiceConsole.tsx +162 -71
  63. package/gui/src/features/workspace/WorkspaceList.tsx +184 -32
  64. package/gui/src/index.css +95 -2
  65. package/package.json +2 -1
  66. package/src/commands/add-repo.ts +30 -14
  67. package/src/commands/commit.ts +1 -1
  68. package/src/commands/diff.ts +1 -1
  69. package/src/commands/doctor.ts +1 -1
  70. package/src/commands/handoff.ts +1 -1
  71. package/src/commands/logs.ts +1 -1
  72. package/src/commands/open.ts +15 -7
  73. package/src/commands/pack.ts +1 -1
  74. package/src/commands/refresh.ts +16 -8
  75. package/src/commands/remove.ts +15 -7
  76. package/src/commands/start.ts +1 -1
  77. package/src/commands/status.ts +1 -1
  78. package/src/commands/stop.ts +1 -1
  79. package/src/commands/sync.ts +16 -8
  80. package/src/core/workspace.ts +48 -1
  81. package/src/generators/index.ts +20 -0
  82. package/src/orchestration/runner.ts +112 -108
  83. package/src/server.ts +58 -1
  84. package/src/utils/detect-editors.test.ts +3 -3
  85. package/src/utils/detect-editors.ts +1 -1
  86. package/src/utils/prompts.ts +88 -14
  87. package/dist/gui/assets/index-Ca2VRMrQ.js +0 -22
  88. package/dist/gui/assets/index-fj0RJiOb.css +0 -2
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { select } from '@inquirer/prompts';
2
+ import { select, search } from '@inquirer/prompts';
3
3
  import * as path from 'node:path';
4
4
  import * as fs from 'node:fs/promises';
5
5
 
@@ -117,7 +117,7 @@ async function resolveWorkspace(workspaceArg?: string): Promise<string | null> {
117
117
  }
118
118
 
119
119
  const cwdFeature = await loadFeatureConfig(process.cwd());
120
- if (cwdFeature) return process.cwd();
120
+ if (cwdFeature) return cwdFeature.workspacePath;
121
121
 
122
122
  const config = await loadConfig();
123
123
  const workspaces = await listWorkspaces(config.workspacesDir);
@@ -127,12 +127,20 @@ async function resolveWorkspace(workspaceArg?: string): Promise<string | null> {
127
127
  return null;
128
128
  }
129
129
 
130
- const selected = await select({
131
- message: 'Select a workspace to refresh:',
132
- choices: workspaces.map((ws) => ({
133
- name: `${ws.branchName} ${chalk.dim(`(${ws.repos.length} repos)`)}`,
134
- value: ws.workspacePath,
135
- })),
130
+ const selected = await search({
131
+ message: 'Search and select a workspace to refresh:',
132
+ source: async (input) => {
133
+ const query = (input || '').toLowerCase();
134
+ const filtered = workspaces.filter(
135
+ (ws) =>
136
+ ws.branchName.toLowerCase().includes(query) ||
137
+ ws.workspacePath.toLowerCase().includes(query)
138
+ );
139
+ return filtered.map((ws) => ({
140
+ name: `${ws.branchName} ${chalk.dim(`(${ws.repos.length} repos)`)}`,
141
+ value: ws.workspacePath,
142
+ }));
143
+ },
136
144
  });
137
145
 
138
146
  return selected;
@@ -5,7 +5,7 @@
5
5
 
6
6
  import chalk from 'chalk';
7
7
  import ora from 'ora';
8
- import { select, confirm } from '@inquirer/prompts';
8
+ import { select, confirm, search } from '@inquirer/prompts';
9
9
  import * as path from 'node:path';
10
10
  import * as fs from 'node:fs/promises';
11
11
 
@@ -52,12 +52,20 @@ export async function removeCommand(workspaceArg?: string): Promise<void> {
52
52
  return;
53
53
  }
54
54
 
55
- const selected = await select({
56
- message: 'Select a workspace to delete:',
57
- choices: workspaces.map((ws) => ({
58
- name: `${ws.branchName} ${chalk.dim(`(${ws.repos.length} repos)`)}`,
59
- value: ws,
60
- })),
55
+ const selected = await search({
56
+ message: 'Search and select a workspace to delete:',
57
+ source: async (input) => {
58
+ const query = (input || '').toLowerCase();
59
+ const filtered = workspaces.filter(
60
+ (ws) =>
61
+ ws.branchName.toLowerCase().includes(query) ||
62
+ ws.workspacePath.toLowerCase().includes(query)
63
+ );
64
+ return filtered.map((ws) => ({
65
+ name: `${ws.branchName} ${chalk.dim(`(${ws.repos.length} repos)`)}`,
66
+ value: ws,
67
+ }));
68
+ },
61
69
  });
62
70
 
63
71
  workspacePath = selected.workspacePath;
@@ -93,7 +93,7 @@ async function resolveWorkspace(workspaceArg?: string): Promise<string | null> {
93
93
 
94
94
  // Check if CWD is a workspace
95
95
  const cwdFeature = await loadFeatureConfig(process.cwd());
96
- if (cwdFeature) return process.cwd();
96
+ if (cwdFeature) return cwdFeature.workspacePath;
97
97
 
98
98
  // Otherwise, list workspaces and let user pick
99
99
  const config = await loadConfig();
@@ -32,7 +32,7 @@ async function resolveWorkspace(workspaceArg?: string): Promise<string | null> {
32
32
  if (workspaceArg) return workspaceArg;
33
33
 
34
34
  const cwdFeature = await loadFeatureConfig(process.cwd());
35
- if (cwdFeature) return process.cwd();
35
+ if (cwdFeature) return cwdFeature.workspacePath;
36
36
 
37
37
  const config = await loadConfig();
38
38
  const workspaces = await listWorkspaces(config.workspacesDir);
@@ -33,7 +33,7 @@ async function resolveWorkspace(workspaceArg?: string): Promise<string | null> {
33
33
  if (workspaceArg) return workspaceArg;
34
34
 
35
35
  const cwdFeature = await loadFeatureConfig(process.cwd());
36
- if (cwdFeature) return process.cwd();
36
+ if (cwdFeature) return cwdFeature.workspacePath;
37
37
 
38
38
  const config = await loadConfig();
39
39
  const workspaces = await listWorkspaces(config.workspacesDir);
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import chalk from 'chalk';
7
- import { select } from '@inquirer/prompts';
7
+ import { select, search } from '@inquirer/prompts';
8
8
  import * as path from 'node:path';
9
9
  import * as fs from 'node:fs/promises';
10
10
 
@@ -123,7 +123,7 @@ async function resolveWorkspace(workspaceArg?: string): Promise<string | null> {
123
123
  }
124
124
 
125
125
  const cwdFeature = await loadFeatureConfig(process.cwd());
126
- if (cwdFeature) return process.cwd();
126
+ if (cwdFeature) return cwdFeature.workspacePath;
127
127
 
128
128
  const config = await loadConfig();
129
129
  const workspaces = await listWorkspaces(config.workspacesDir);
@@ -133,12 +133,20 @@ async function resolveWorkspace(workspaceArg?: string): Promise<string | null> {
133
133
  return null;
134
134
  }
135
135
 
136
- const selected = await select({
137
- message: 'Select a workspace to sync:',
138
- choices: workspaces.map((ws) => ({
139
- name: `${ws.branchName} ${chalk.dim(`(${ws.repos.length} repos)`)}`,
140
- value: ws.workspacePath,
141
- })),
136
+ const selected = await search({
137
+ message: 'Search and select a workspace to sync:',
138
+ source: async (input) => {
139
+ const query = (input || '').toLowerCase();
140
+ const filtered = workspaces.filter(
141
+ (ws) =>
142
+ ws.branchName.toLowerCase().includes(query) ||
143
+ ws.workspacePath.toLowerCase().includes(query)
144
+ );
145
+ return filtered.map((ws) => ({
146
+ name: `${ws.branchName} ${chalk.dim(`(${ws.repos.length} repos)`)}`,
147
+ value: ws.workspacePath,
148
+ }));
149
+ },
142
150
  });
143
151
 
144
152
  return selected;
@@ -82,6 +82,27 @@ export async function createWorkspace(
82
82
  console.warn('Warning: Failed to create .vscode/settings.json:', error);
83
83
  }
84
84
 
85
+ // Create .cursor/mcp.json for workspace-local Cursor MCP configuration
86
+ try {
87
+ const cursorDir = path.join(workspacePath, '.cursor');
88
+ await fs.mkdir(cursorDir, { recursive: true });
89
+ const cursorMcp = {
90
+ "mcpServers": {
91
+ "nexusflow": {
92
+ "command": "npx",
93
+ "args": ["-y", "@mrpatronz/nexusflow", "mcp", "run", workspacePath]
94
+ }
95
+ }
96
+ };
97
+ await fs.writeFile(
98
+ path.join(cursorDir, 'mcp.json'),
99
+ JSON.stringify(cursorMcp, null, 2) + '\n',
100
+ 'utf-8'
101
+ );
102
+ } catch (error) {
103
+ console.warn('Warning: Failed to create .cursor/mcp.json:', error);
104
+ }
105
+
85
106
  // Create a worktree for each repo inside the workspace.
86
107
  for (const repo of repos) {
87
108
  const worktreeTarget = path.join(workspacePath, repo.name);
@@ -173,8 +194,10 @@ export async function saveFeatureConfig(
173
194
  export async function loadFeatureConfig(
174
195
  workspacePath: string,
175
196
  ): Promise<Feature | null> {
176
- const manifestPath = path.join(workspacePath, MANIFEST_FILE);
197
+ const rootDir = await findWorkspaceRoot(workspacePath);
198
+ if (!rootDir) return null;
177
199
 
200
+ const manifestPath = path.join(rootDir, MANIFEST_FILE);
178
201
  try {
179
202
  const raw = await fs.readFile(manifestPath, 'utf-8');
180
203
  return JSON.parse(raw) as Feature;
@@ -183,6 +206,30 @@ export async function loadFeatureConfig(
183
206
  }
184
207
  }
185
208
 
209
+ /**
210
+ * Traverses up parent directories to find a directory containing `nexusflow.json`.
211
+ *
212
+ * @param startPath - Path to start searching from.
213
+ * @returns Absolute path to the workspace root directory, or null if not found.
214
+ */
215
+ export async function findWorkspaceRoot(startPath: string): Promise<string | null> {
216
+ let current = path.resolve(startPath);
217
+ while (true) {
218
+ const manifestPath = path.join(current, MANIFEST_FILE);
219
+ try {
220
+ await fs.access(manifestPath);
221
+ return current;
222
+ } catch {
223
+ const parent = path.dirname(current);
224
+ if (parent === current) {
225
+ break; // Reached root directory
226
+ }
227
+ current = parent;
228
+ }
229
+ }
230
+ return null;
231
+ }
232
+
186
233
  /**
187
234
  * Resolves a repo path to a full RepoInfo object.
188
235
  *
@@ -178,6 +178,7 @@ export async function generateContextFiles(
178
178
  }
179
179
  }
180
180
 
181
+ // Generate configuration for each selected assistant
181
182
  for (const assistant of assistants) {
182
183
  const entry = GENERATORS[assistant];
183
184
 
@@ -196,6 +197,25 @@ export async function generateContextFiles(
196
197
  }
197
198
  }
198
199
 
200
+ // Ensure AGENTS.md is always generated if it is not already generated as a primary assistant file,
201
+ // since it is the universal standard for AI coding agents.
202
+ const hasAgentsGen = assistants.includes('antigravity') || assistants.includes('codex');
203
+ if (!hasAgentsGen) {
204
+ try {
205
+ await generateAntigravityConfig(ctx, workspacePath);
206
+ console.log(
207
+ chalk.green(' ✔'),
208
+ `Generated fallback ${chalk.bold('AGENTS.md')} for harness context`,
209
+ );
210
+ } catch (error) {
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ console.error(
213
+ chalk.red(' ✖'),
214
+ `Failed to generate fallback AGENTS.md: ${message}`,
215
+ );
216
+ }
217
+ }
218
+
199
219
 
200
220
  // Generate implementation plan from dependency analysis (if analysis data available)
201
221
  try {
@@ -1,22 +1,19 @@
1
1
  /**
2
2
  * @module orchestration/runner
3
3
  * Manages the lifecycle of services — start, stop, status, and log streaming.
4
- * Spawns child processes and tracks them via a state file.
4
+ * Uses PM2 for process management.
5
5
  */
6
6
 
7
7
  import * as fs from 'node:fs/promises';
8
8
  import * as path from 'node:path';
9
- import { spawn, type ChildProcess } from 'node:child_process';
10
9
  import chalk from 'chalk';
10
+ import { execa } from 'execa';
11
11
 
12
12
  import type { ServiceConfig, RunningService, RunningState } from '../types.js';
13
13
 
14
14
  /** Name of the state file that tracks running services. */
15
15
  const STATE_FILE = '.nexusflow-running.json';
16
16
 
17
- /** Active child processes tracked in memory. */
18
- const activeProcesses = new Map<string, ChildProcess>();
19
-
20
17
  /**
21
18
  * Returns the path to the running-state file for a workspace.
22
19
  */
@@ -25,12 +22,39 @@ function getStatePath(workspacePath: string): string {
25
22
  }
26
23
 
27
24
  /**
28
- * Loads the running state from disk.
25
+ * Loads the running state from disk, syncing active status with PM2.
29
26
  */
30
27
  export async function loadRunningState(workspacePath: string): Promise<RunningState | null> {
31
28
  try {
32
29
  const raw = await fs.readFile(getStatePath(workspacePath), 'utf-8');
33
- return JSON.parse(raw) as RunningState;
30
+ const state = JSON.parse(raw) as RunningState;
31
+
32
+ try {
33
+ // Query current PM2 process list to verify actual running status
34
+ const { stdout } = await execa('npx', ['pm2', 'jlist']);
35
+ const pm2List = JSON.parse(stdout);
36
+ const workspaceId = path.basename(workspacePath);
37
+ const prefix = `nexusflow-${workspaceId}-`;
38
+
39
+ const activeServices = state.services.map((service) => {
40
+ const uniqueName = `${prefix}${service.name}`;
41
+ const pm2App = pm2List.find((app: any) => app.name === uniqueName);
42
+ const running = pm2App && pm2App.pm2_env?.status === 'online';
43
+ return {
44
+ ...service,
45
+ pid: running ? (pm2App.pid || service.pid) : 0,
46
+ };
47
+ }).filter((service) => service.pid > 0);
48
+
49
+ return {
50
+ ...state,
51
+ services: activeServices,
52
+ updatedAt: new Date().toISOString(),
53
+ };
54
+ } catch {
55
+ // Fallback to cached state on disk if PM2 query fails
56
+ return state;
57
+ }
34
58
  } catch {
35
59
  return null;
36
60
  }
@@ -56,20 +80,7 @@ async function clearRunningState(workspacePath: string): Promise<void> {
56
80
  }
57
81
 
58
82
  /**
59
- * Checks if a process with the given PID is still running.
60
- */
61
- function isProcessRunning(pid: number): boolean {
62
- try {
63
- process.kill(pid, 0);
64
- return true;
65
- } catch {
66
- return false;
67
- }
68
- }
69
-
70
- /**
71
- * Starts all services in the given list.
72
- * Each service is spawned as a detached child process.
83
+ * Starts all services in the given list using PM2.
73
84
  *
74
85
  * @param services - Services to start.
75
86
  * @param workspacePath - Workspace root path.
@@ -84,63 +95,66 @@ export async function startServices(
84
95
  await fs.mkdir(logDir, { recursive: true });
85
96
 
86
97
  const runningServices: RunningService[] = [];
98
+ const workspaceId = path.basename(workspacePath);
87
99
 
88
100
  for (const service of services) {
89
101
  const logFile = path.join(logDir, `${service.name}.log`);
90
102
  const portStr = service.port ? ` on port ${service.port}` : '';
103
+ const uniqueName = `nexusflow-${workspaceId}-${service.name}`;
91
104
 
92
105
  console.log(
93
- chalk.cyan(` Starting ${chalk.bold(service.name)}${portStr}...`),
106
+ chalk.cyan(` Starting ${chalk.bold(service.name)}${portStr} under PM2...`),
94
107
  );
95
108
  console.log(
96
109
  chalk.dim(` ${service.command} ${service.args.join(' ')} (in ${service.cwd})`),
97
110
  );
98
111
 
99
112
  try {
100
- // Ensure parent directory for this log file exists (supports nested names)
113
+ // Ensure parent directory for this log file exists
101
114
  await fs.mkdir(path.dirname(logFile), { recursive: true });
102
- // Open log file for writing
103
- const logFd = await fs.open(logFile, 'w');
104
- const logStream = logFd.createWriteStream();
105
-
106
- const child = spawn(service.command, service.args, {
107
- cwd: service.cwd,
108
- stdio: ['ignore', 'pipe', 'pipe'],
109
- detached: true,
110
- shell: true,
111
- });
112
-
113
- // Pipe stdout and stderr to log file
114
- child.stdout?.pipe(logStream);
115
- child.stderr?.pipe(logStream);
116
-
117
- child.on('error', (err) => {
118
- console.error(chalk.red(` ✖ ${service.name} error: ${err.message}`));
119
- });
120
-
121
- child.on('exit', (code) => {
122
- if (code !== null && code !== 0) {
123
- console.log(chalk.yellow(` ⚠ ${service.name} exited with code ${code}`));
124
- }
125
- activeProcesses.delete(service.name);
126
- });
127
-
128
- // Don't wait for the child to finish
129
- child.unref();
130
-
131
- if (child.pid) {
132
- activeProcesses.set(service.name, child);
115
+
116
+ // Delete any existing PM2 configuration with the same name to avoid duplicate starts
117
+ await execa('npx', ['pm2', 'delete', uniqueName], { reject: false });
118
+
119
+ // Start the service using PM2 as a direct process execution
120
+ await execa('npx', [
121
+ 'pm2',
122
+ 'start',
123
+ service.command,
124
+ '--name',
125
+ uniqueName,
126
+ '--cwd',
127
+ service.cwd,
128
+ '-o',
129
+ logFile,
130
+ '-e',
131
+ logFile,
132
+ '--interpreter',
133
+ 'none',
134
+ '--',
135
+ ...service.args
136
+ ]);
137
+
138
+ // Retrieve the real PID from PM2
139
+ const { stdout } = await execa('npx', ['pm2', 'jlist']);
140
+ const pm2List = JSON.parse(stdout);
141
+ const pm2App = pm2List.find((app: any) => app.name === uniqueName);
142
+ const pid = pm2App?.pid || 0;
143
+
144
+ if (pid) {
133
145
  runningServices.push({
134
146
  name: service.name,
135
- pid: child.pid,
147
+ pid,
136
148
  config: service,
137
149
  startedAt: new Date().toISOString(),
138
150
  });
139
- console.log(chalk.green(` ✔ ${service.name} started (PID: ${child.pid})`));
151
+ console.log(chalk.green(` ✔ ${service.name} started under PM2 (PID: ${pid})`));
152
+ } else {
153
+ console.warn(chalk.yellow(` ⚠ Started ${service.name} but could not resolve PID from PM2.`));
140
154
  }
141
155
  } catch (error) {
142
156
  const msg = error instanceof Error ? error.message : String(error);
143
- console.error(chalk.red(` ✖ Failed to start ${service.name}: ${msg}`));
157
+ console.error(chalk.red(` ✖ Failed to start ${service.name} via PM2: ${msg}`));
144
158
  }
145
159
  }
146
160
 
@@ -155,79 +169,69 @@ export async function startServices(
155
169
  }
156
170
 
157
171
  /**
158
- * Stops all services for a workspace.
172
+ * Stops all services for a workspace under PM2.
159
173
  *
160
174
  * @param workspacePath - Workspace root path.
161
175
  */
162
176
  export async function stopServices(workspacePath: string): Promise<void> {
163
- const state = await loadRunningState(workspacePath);
177
+ const workspaceId = path.basename(workspacePath);
178
+ const prefix = `nexusflow-${workspaceId}-`;
164
179
 
165
- if (!state || state.services.length === 0) {
166
- console.log(chalk.yellow(' No running services found.'));
167
- return;
168
- }
180
+ console.log(chalk.cyan(` Stopping all services under PM2 for workspace: ${workspaceId}...`));
169
181
 
170
- for (const service of state.services) {
171
- // Try in-memory process first
172
- const child = activeProcesses.get(service.name);
173
- if (child && child.pid) {
174
- try {
175
- // Kill the process group (negative PID kills group on Unix, taskkill on Windows)
176
- if (process.platform === 'win32') {
177
- spawn('taskkill', ['/pid', String(child.pid), '/f', '/t'], { stdio: 'ignore' });
178
- } else {
179
- process.kill(-child.pid, 'SIGTERM');
180
- }
181
- console.log(chalk.green(` ✔ Stopped ${service.name} (PID: ${service.pid})`));
182
- activeProcesses.delete(service.name);
183
- continue;
184
- } catch {
185
- // Fall through to PID-based kill
186
- }
187
- }
182
+ try {
183
+ const { stdout } = await execa('npx', ['pm2', 'jlist']);
184
+ const pm2List = JSON.parse(stdout);
185
+ const targetApps = pm2List.filter((app: any) => app.name && app.name.startsWith(prefix));
188
186
 
189
- // Fall back to PID from state file
190
- if (isProcessRunning(service.pid)) {
191
- try {
192
- if (process.platform === 'win32') {
193
- spawn('taskkill', ['/pid', String(service.pid), '/f', '/t'], { stdio: 'ignore' });
194
- } else {
195
- process.kill(service.pid, 'SIGTERM');
196
- }
197
- console.log(chalk.green(` ✔ Stopped ${service.name} (PID: ${service.pid})`));
198
- } catch {
199
- console.log(chalk.yellow(` ⚠ Could not stop ${service.name} (PID: ${service.pid})`));
200
- }
187
+ if (targetApps.length === 0) {
188
+ console.log(chalk.yellow(' No running services found for this workspace.'));
201
189
  } else {
202
- console.log(chalk.dim(` ${service.name} already stopped`));
190
+ for (const app of targetApps) {
191
+ console.log(chalk.dim(` Stopping PM2 process: ${app.name}`));
192
+ await execa('npx', ['pm2', 'delete', app.name], { reject: false });
193
+ }
194
+ console.log(chalk.green(` ✔ All services stopped.`));
203
195
  }
196
+ } catch (error: any) {
197
+ console.error(chalk.red(` ✖ Failed to stop services via PM2: ${error.message}`));
204
198
  }
205
199
 
206
200
  await clearRunningState(workspacePath);
207
201
  }
208
202
 
209
203
  /**
210
- * Shows the status of all services in a workspace.
204
+ * Shows the status of all services in a workspace by querying PM2.
211
205
  *
212
206
  * @param workspacePath - Workspace root path.
213
207
  */
214
208
  export async function getServiceStatus(workspacePath: string): Promise<void> {
215
- const state = await loadRunningState(workspacePath);
209
+ const workspaceId = path.basename(workspacePath);
210
+ const prefix = `nexusflow-${workspaceId}-`;
216
211
 
217
- if (!state || state.services.length === 0) {
218
- console.log(chalk.yellow(' No services tracked for this workspace.'));
219
- return;
220
- }
212
+ try {
213
+ const { stdout } = await execa('npx', ['pm2', 'jlist']);
214
+ const pm2List = JSON.parse(stdout);
215
+ const targetApps = pm2List.filter((app: any) => app.name && app.name.startsWith(prefix));
221
216
 
222
- for (const service of state.services) {
223
- const running = isProcessRunning(service.pid);
224
- const status = running ? chalk.green('● running') : chalk.red('● stopped');
225
- const portStr = service.config.port ? ` :${service.config.port}` : '';
226
- const since = new Date(service.startedAt).toLocaleTimeString();
217
+ if (targetApps.length === 0) {
218
+ console.log(chalk.yellow(' No running services found for this workspace in PM2.'));
219
+ return;
220
+ }
227
221
 
228
- console.log(
229
- ` ${status} ${chalk.bold(service.name)}${chalk.dim(portStr)} (PID: ${service.pid}, since ${since})`,
230
- );
222
+ for (const app of targetApps) {
223
+ const name = app.name.substring(prefix.length);
224
+ const running = app.pm2_env?.status === 'online';
225
+ const status = running ? chalk.green('● running') : chalk.red(`● ${app.pm2_env?.status || 'stopped'}`);
226
+ const pid = app.pid || 'N/A';
227
+ const uptime = app.pm2_env?.pm_uptime ? new Date(app.pm2_env.pm_uptime).toLocaleTimeString() : 'unknown';
228
+
229
+ console.log(
230
+ ` ${status} ${chalk.bold(name)} (PID: ${pid}, since ${uptime})`,
231
+ );
232
+ }
233
+ } catch (error: any) {
234
+ console.error(chalk.red(` ✖ Failed to query PM2 status: ${error.message}`));
231
235
  }
232
236
  }
233
237
 
package/src/server.ts CHANGED
@@ -46,7 +46,7 @@ const guiPath = path.join(__dirname, 'gui');
46
46
  export const app = new Hono();
47
47
 
48
48
  // Allowed editor binaries/scripts to prevent command injection
49
- const ALLOWED_EDITORS = new Set(['code', 'code-insiders', 'cursor', 'agy', 'idea', 'charm', 'webstorm', 'subl', 'nano', 'vim', 'nvim', 'emacs']);
49
+ const ALLOWED_EDITORS = new Set(['code', 'code-insiders', 'cursor', 'antigravity', 'agy', 'idea', 'charm', 'webstorm', 'subl', 'nano', 'vim', 'nvim', 'emacs']);
50
50
 
51
51
  // Enable CORS for frontend dev server
52
52
  app.use('/api/*', cors());
@@ -700,6 +700,63 @@ app.get('/api/workspace/:id/changes', async (c) => {
700
700
  }
701
701
  });
702
702
 
703
+ // 13_diff. Get git diff for a specific file in workspace sub-repositories
704
+ app.get('/api/workspace/:id/changes/diff', async (c) => {
705
+ try {
706
+ const id = c.req.param('id');
707
+ const repoName = c.req.query('repo');
708
+ const filePath = c.req.query('file');
709
+
710
+ if (!repoName || !filePath) {
711
+ return c.json({ error: 'Missing repo or file query parameter.' }, 400);
712
+ }
713
+
714
+ const config = await loadConfig();
715
+ const workspacePath = path.join(config.workspacesDir, id);
716
+ const worktreePath = path.join(workspacePath, repoName);
717
+
718
+ // Security check: ensure path is within workspace directory
719
+ const resolvedPath = path.resolve(worktreePath);
720
+ if (!resolvedPath.startsWith(path.resolve(workspacePath))) {
721
+ return c.json({ error: 'Invalid repository path.' }, 400);
722
+ }
723
+
724
+ let diff = '';
725
+
726
+ // Check git status for the file to know if it's untracked
727
+ try {
728
+ const { stdout: statusOut } = await execa('git', ['status', '--porcelain', '--', filePath], { cwd: worktreePath });
729
+ const statusLine = statusOut.trim();
730
+ const isUntracked = statusLine.startsWith('??');
731
+
732
+ if (isUntracked) {
733
+ const result = await execa('git', ['diff', '--no-index', '--', '/dev/null', filePath], {
734
+ cwd: worktreePath,
735
+ reject: false
736
+ });
737
+ diff = result.stdout || result.stderr || '';
738
+ } else {
739
+ const result = await execa('git', ['diff', 'HEAD', '--', filePath], {
740
+ cwd: worktreePath,
741
+ reject: false
742
+ });
743
+ diff = result.stdout || result.stderr || '';
744
+ }
745
+ } catch (e) {
746
+ const result = await execa('git', ['diff', 'HEAD', '--', filePath], {
747
+ cwd: worktreePath,
748
+ reject: false
749
+ });
750
+ diff = result.stdout || result.stderr || '';
751
+ }
752
+
753
+ return c.json({ diff });
754
+ } catch (error) {
755
+ const msg = error instanceof Error ? error.message : String(error);
756
+ return c.json({ error: msg }, 500);
757
+ }
758
+ });
759
+
703
760
  // 13a. Get workspace knowledge (nexusflow-knowledge.md)
704
761
  app.get('/api/workspace/:id/knowledge', async (c) => {
705
762
  try {