@nogataka/smart-edit 1.0.2 → 1.0.4
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/README.md +201 -65
- package/dist/smart-edit/cli.js +79 -2
- package/dist/smart-edit/dashboard.js +29 -0
- package/dist/smart-edit/instance-registry.d.ts +43 -0
- package/dist/smart-edit/instance-registry.js +272 -0
- package/dist/smart-edit/resources/dashboard/dashboard.js +9 -9
- package/dist/smart-edit/resources/dashboard/index.css +1 -1
- package/dist/smart-edit/standalone-dashboard.d.ts +32 -0
- package/dist/smart-edit/standalone-dashboard.js +223 -0
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
import process from 'node:process';
|
|
8
8
|
import { createSmartEditLogger } from './util/logging.js';
|
|
9
9
|
import { SMART_EDIT_DASHBOARD_DIR } from './constants.js';
|
|
10
|
+
import { getInstances } from './instance-registry.js';
|
|
10
11
|
const { logger } = createSmartEditLogger({ name: 'smart-edit.dashboard', emitToConsole: false, level: 'info' });
|
|
11
12
|
const DEFAULT_DASHBOARD_PORT = 0x5eda;
|
|
12
13
|
const DASHBOARD_HOST = '127.0.0.1';
|
|
@@ -292,6 +293,16 @@ export class SmartEditDashboardAPI {
|
|
|
292
293
|
const method = req.method?.toUpperCase() ?? 'GET';
|
|
293
294
|
const url = parseUrl(req.url ?? '/', true);
|
|
294
295
|
const pathname = url.pathname ?? '/';
|
|
296
|
+
// Add CORS headers for cross-origin requests from multi-instance dashboard
|
|
297
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
298
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
|
|
299
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
300
|
+
// Handle CORS preflight
|
|
301
|
+
if (method === 'OPTIONS') {
|
|
302
|
+
res.statusCode = 204;
|
|
303
|
+
res.end();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
295
306
|
try {
|
|
296
307
|
if (pathname.startsWith('/dashboard')) {
|
|
297
308
|
if (method !== 'GET') {
|
|
@@ -356,6 +367,24 @@ export class SmartEditDashboardAPI {
|
|
|
356
367
|
this.shutdown();
|
|
357
368
|
this.sendJson(res, 200, { status: 'shutting down' });
|
|
358
369
|
return;
|
|
370
|
+
// Multi-instance dashboard APIs
|
|
371
|
+
case '/api/instances':
|
|
372
|
+
if (method !== 'GET') {
|
|
373
|
+
this.respondMethodNotAllowed(res);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
this.sendJson(res, 200, { instances: getInstances() });
|
|
377
|
+
return;
|
|
378
|
+
case '/api/instance-info':
|
|
379
|
+
if (method !== 'GET') {
|
|
380
|
+
this.respondMethodNotAllowed(res);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
this.sendJson(res, 200, {
|
|
384
|
+
port: this.listeningPort,
|
|
385
|
+
project: this.agent.getActiveProject()?.projectName ?? null
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
359
388
|
default:
|
|
360
389
|
this.respondNotFound(res);
|
|
361
390
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instance Registry for Multi-Instance Dashboard
|
|
3
|
+
*
|
|
4
|
+
* Manages registration of multiple Smart Edit MCP server instances.
|
|
5
|
+
* Each instance registers itself when starting and unregisters when stopping.
|
|
6
|
+
* The dashboard reads this registry to discover and connect to all running instances.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_DASHBOARD_PORT = 24282;
|
|
9
|
+
export interface InstanceInfo {
|
|
10
|
+
id: string;
|
|
11
|
+
port: number;
|
|
12
|
+
project: string | null;
|
|
13
|
+
pid: number;
|
|
14
|
+
startedAt: string;
|
|
15
|
+
transport: 'stdio' | 'sse' | 'streamable-http';
|
|
16
|
+
}
|
|
17
|
+
export declare function generateInstanceId(): string;
|
|
18
|
+
/**
|
|
19
|
+
* Register a new MCP server instance in the registry.
|
|
20
|
+
* This function is designed to be non-fatal - if registration fails, the instance
|
|
21
|
+
* will still work, just won't be visible in the multi-instance dashboard.
|
|
22
|
+
*/
|
|
23
|
+
export declare function registerInstance(info: Omit<InstanceInfo, 'id' | 'startedAt'>): InstanceInfo;
|
|
24
|
+
/**
|
|
25
|
+
* Unregister an MCP server instance from the registry.
|
|
26
|
+
*/
|
|
27
|
+
export declare function unregisterInstance(instanceId: string): void;
|
|
28
|
+
/**
|
|
29
|
+
* Get all registered instances (with cleanup of dead processes).
|
|
30
|
+
*/
|
|
31
|
+
export declare function getInstances(): InstanceInfo[];
|
|
32
|
+
/**
|
|
33
|
+
* Get a specific instance by ID.
|
|
34
|
+
*/
|
|
35
|
+
export declare function getInstance(instanceId: string): InstanceInfo | null;
|
|
36
|
+
/**
|
|
37
|
+
* Find an available port for the dashboard, starting from the default port.
|
|
38
|
+
*/
|
|
39
|
+
export declare function findAvailablePort(startPort?: number): number;
|
|
40
|
+
/**
|
|
41
|
+
* Update instance info (e.g., when project changes).
|
|
42
|
+
*/
|
|
43
|
+
export declare function updateInstance(instanceId: string, updates: Partial<Pick<InstanceInfo, 'project'>>): void;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instance Registry for Multi-Instance Dashboard
|
|
3
|
+
*
|
|
4
|
+
* Manages registration of multiple Smart Edit MCP server instances.
|
|
5
|
+
* Each instance registers itself when starting and unregisters when stopping.
|
|
6
|
+
* The dashboard reads this registry to discover and connect to all running instances.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import process from 'node:process';
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { SMART_EDIT_MANAGED_DIR_NAME } from './constants.js';
|
|
14
|
+
import { createSmartEditLogger } from './util/logging.js';
|
|
15
|
+
const { logger } = createSmartEditLogger({ name: 'smart-edit.instance-registry', emitToConsole: false, level: 'info' });
|
|
16
|
+
export const DEFAULT_DASHBOARD_PORT = 0x5eda; // 24282
|
|
17
|
+
// Compute paths dynamically to respect runtime HOME changes (important for testing)
|
|
18
|
+
// Use process.env.HOME/USERPROFILE first as os.homedir() may cache the value
|
|
19
|
+
function getSmartEditDir() {
|
|
20
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
|
|
21
|
+
return path.join(home, SMART_EDIT_MANAGED_DIR_NAME);
|
|
22
|
+
}
|
|
23
|
+
function getInstancesFilePath() {
|
|
24
|
+
return path.join(getSmartEditDir(), 'instances.json');
|
|
25
|
+
}
|
|
26
|
+
function getLockFilePath() {
|
|
27
|
+
return path.join(getSmartEditDir(), 'instances.lock');
|
|
28
|
+
}
|
|
29
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
30
|
+
const LOCK_RETRY_INTERVAL_MS = 50;
|
|
31
|
+
function ensureDirectoryExists(filePath) {
|
|
32
|
+
const dir = path.dirname(filePath);
|
|
33
|
+
if (!fs.existsSync(dir)) {
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function acquireLock() {
|
|
38
|
+
try {
|
|
39
|
+
const lockFile = getLockFilePath();
|
|
40
|
+
ensureDirectoryExists(lockFile);
|
|
41
|
+
const startTime = Date.now();
|
|
42
|
+
while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
|
|
43
|
+
try {
|
|
44
|
+
fs.writeFileSync(lockFile, String(process.pid), { flag: 'wx' });
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
const err = error;
|
|
49
|
+
if (err.code === 'EEXIST') {
|
|
50
|
+
// Lock file exists, check if the process is still alive
|
|
51
|
+
try {
|
|
52
|
+
const lockPid = Number.parseInt(fs.readFileSync(lockFile, 'utf-8').trim(), 10);
|
|
53
|
+
if (!Number.isNaN(lockPid)) {
|
|
54
|
+
try {
|
|
55
|
+
// Check if process is alive (signal 0 doesn't kill, just checks)
|
|
56
|
+
process.kill(lockPid, 0);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Process is dead, remove stale lock
|
|
60
|
+
try {
|
|
61
|
+
fs.unlinkSync(lockFile);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Ignore unlink errors
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Can't read lock file, try to remove it
|
|
72
|
+
try {
|
|
73
|
+
fs.unlinkSync(lockFile);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Ignore unlink errors
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Brief busy-wait before retry
|
|
81
|
+
const waitUntil = Date.now() + Math.min(LOCK_RETRY_INTERVAL_MS, LOCK_TIMEOUT_MS - (Date.now() - startTime));
|
|
82
|
+
while (Date.now() < waitUntil) {
|
|
83
|
+
// Busy wait - acceptable since lock contention should be rare
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// For other errors (EACCES, ENOENT, etc.), give up on locking
|
|
88
|
+
logger.warn(`Lock acquisition failed with error: ${err.code}`, err);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
logger.warn('Failed to acquire lock for instance registry (timeout)');
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
// Catch any unexpected errors (e.g., from ensureDirectoryExists)
|
|
97
|
+
logger.warn('Unexpected error during lock acquisition', error instanceof Error ? error : undefined);
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function releaseLock() {
|
|
102
|
+
try {
|
|
103
|
+
fs.unlinkSync(getLockFilePath());
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Ignore errors when releasing lock
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function readRegistry() {
|
|
110
|
+
const instancesFile = getInstancesFilePath();
|
|
111
|
+
ensureDirectoryExists(instancesFile);
|
|
112
|
+
try {
|
|
113
|
+
if (fs.existsSync(instancesFile)) {
|
|
114
|
+
const content = fs.readFileSync(instancesFile, 'utf-8');
|
|
115
|
+
const data = JSON.parse(content);
|
|
116
|
+
if (data && typeof data === 'object' && Array.isArray(data.instances)) {
|
|
117
|
+
return data;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
logger.warn('Failed to read instance registry, starting fresh', error instanceof Error ? error : undefined);
|
|
123
|
+
}
|
|
124
|
+
return { instances: [] };
|
|
125
|
+
}
|
|
126
|
+
function writeRegistry(data) {
|
|
127
|
+
const instancesFile = getInstancesFilePath();
|
|
128
|
+
ensureDirectoryExists(instancesFile);
|
|
129
|
+
// Atomic write: write to temp file first, then rename
|
|
130
|
+
const tempFile = `${instancesFile}.tmp`;
|
|
131
|
+
fs.writeFileSync(tempFile, JSON.stringify(data, null, 2), 'utf-8');
|
|
132
|
+
fs.renameSync(tempFile, instancesFile);
|
|
133
|
+
}
|
|
134
|
+
function isProcessAlive(pid) {
|
|
135
|
+
try {
|
|
136
|
+
process.kill(pid, 0);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function cleanupDeadInstances(data) {
|
|
144
|
+
const aliveInstances = data.instances.filter((instance) => isProcessAlive(instance.pid));
|
|
145
|
+
if (aliveInstances.length !== data.instances.length) {
|
|
146
|
+
logger.info(`Cleaned up ${data.instances.length - aliveInstances.length} dead instance(s) from registry`);
|
|
147
|
+
}
|
|
148
|
+
return { instances: aliveInstances };
|
|
149
|
+
}
|
|
150
|
+
export function generateInstanceId() {
|
|
151
|
+
return crypto.randomBytes(6).toString('hex');
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Register a new MCP server instance in the registry.
|
|
155
|
+
* This function is designed to be non-fatal - if registration fails, the instance
|
|
156
|
+
* will still work, just won't be visible in the multi-instance dashboard.
|
|
157
|
+
*/
|
|
158
|
+
export function registerInstance(info) {
|
|
159
|
+
const id = generateInstanceId();
|
|
160
|
+
const instance = {
|
|
161
|
+
...info,
|
|
162
|
+
id,
|
|
163
|
+
startedAt: new Date().toISOString()
|
|
164
|
+
};
|
|
165
|
+
try {
|
|
166
|
+
if (!acquireLock()) {
|
|
167
|
+
logger.warn('Failed to acquire lock for registering instance, continuing without registration');
|
|
168
|
+
return instance;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
let data = readRegistry();
|
|
172
|
+
data = cleanupDeadInstances(data);
|
|
173
|
+
// Check for duplicate port (shouldn't happen, but just in case)
|
|
174
|
+
const existingIndex = data.instances.findIndex((i) => i.port === info.port);
|
|
175
|
+
if (existingIndex !== -1) {
|
|
176
|
+
data.instances.splice(existingIndex, 1);
|
|
177
|
+
}
|
|
178
|
+
data.instances.push(instance);
|
|
179
|
+
writeRegistry(data);
|
|
180
|
+
logger.info(`Registered instance ${id} on port ${info.port} for project: ${info.project ?? '(none)'}`);
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
releaseLock();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
logger.warn('Failed to register instance in registry', error instanceof Error ? error : undefined);
|
|
188
|
+
}
|
|
189
|
+
return instance;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Unregister an MCP server instance from the registry.
|
|
193
|
+
*/
|
|
194
|
+
export function unregisterInstance(instanceId) {
|
|
195
|
+
if (!acquireLock()) {
|
|
196
|
+
logger.error('Failed to acquire lock for unregistering instance');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const data = readRegistry();
|
|
201
|
+
const index = data.instances.findIndex((i) => i.id === instanceId);
|
|
202
|
+
if (index !== -1) {
|
|
203
|
+
const removed = data.instances.splice(index, 1)[0];
|
|
204
|
+
writeRegistry(data);
|
|
205
|
+
logger.info(`Unregistered instance ${instanceId} (port: ${removed.port})`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
releaseLock();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Get all registered instances (with cleanup of dead processes).
|
|
214
|
+
*/
|
|
215
|
+
export function getInstances() {
|
|
216
|
+
if (!acquireLock()) {
|
|
217
|
+
// Even if we can't acquire lock, try to read
|
|
218
|
+
const data = readRegistry();
|
|
219
|
+
return data.instances.filter((instance) => isProcessAlive(instance.pid));
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
let data = readRegistry();
|
|
223
|
+
data = cleanupDeadInstances(data);
|
|
224
|
+
writeRegistry(data);
|
|
225
|
+
return data.instances;
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
releaseLock();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Get a specific instance by ID.
|
|
233
|
+
*/
|
|
234
|
+
export function getInstance(instanceId) {
|
|
235
|
+
const instances = getInstances();
|
|
236
|
+
return instances.find((i) => i.id === instanceId) ?? null;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Find an available port for the dashboard, starting from the default port.
|
|
240
|
+
*/
|
|
241
|
+
export function findAvailablePort(startPort = DEFAULT_DASHBOARD_PORT) {
|
|
242
|
+
const instances = getInstances();
|
|
243
|
+
const usedPorts = new Set(instances.map((i) => i.port));
|
|
244
|
+
let port = startPort;
|
|
245
|
+
while (usedPorts.has(port) && port <= 65535) {
|
|
246
|
+
port++;
|
|
247
|
+
}
|
|
248
|
+
return port;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Update instance info (e.g., when project changes).
|
|
252
|
+
*/
|
|
253
|
+
export function updateInstance(instanceId, updates) {
|
|
254
|
+
if (!acquireLock()) {
|
|
255
|
+
logger.error('Failed to acquire lock for updating instance');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const data = readRegistry();
|
|
260
|
+
const instance = data.instances.find((i) => i.id === instanceId);
|
|
261
|
+
if (instance) {
|
|
262
|
+
if (updates.project !== undefined) {
|
|
263
|
+
instance.project = updates.project;
|
|
264
|
+
}
|
|
265
|
+
writeRegistry(data);
|
|
266
|
+
logger.info(`Updated instance ${instanceId}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
releaseLock();
|
|
271
|
+
}
|
|
272
|
+
}
|