@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.
@@ -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
+ }