Skip to content
112 changes: 111 additions & 1 deletion apps/vscode/src/engine/local/engine-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export class EngineLocal extends EngineBackend {
/** True while stopProcess() is in progress — suppresses spurious error events from the exit handler. */
private stopping = false;

/** Local-node symlinks created under <engineDir>/nodes/src/nodes/ for this run; removed on stop. */
private linkedNodes: string[] = [];

/**
* @param parentDir - Parent directory for the engine/ subdirectory.
* Engine binaries live at <parentDir>/engine/engine(.exe).
Expand Down Expand Up @@ -158,7 +161,20 @@ export class EngineLocal extends EngineBackend {
...effectiveArgs,
];

await this.spawnProcess(executablePath, args);
// Wipe links from any prior start() attempt on this same backend so
// nodes removed from the workspace don't linger and so a retry can't
// accumulate duplicates in linkedNodes.
this.cleanupLocalNodes();
this.setupLocalNodes(path.dirname(executablePath));

try {
await this.spawnProcess(executablePath, args);
} catch (err) {
// Spawn failed — roll back the symlinks we just created so a
// failed start doesn't leak workspace-local links into the install.
this.cleanupLocalNodes();
throw err;
}
this.logger.output(`${icons.success} Local server started on port ${this.actualPort}`);

const installed = this.installer.getInstalledVersion();
Expand All @@ -176,6 +192,7 @@ export class EngineLocal extends EngineBackend {
async stop(): Promise<void> {
this.emitStatus({ phase: 'working', message: 'Stopping server...' });
await this.stopProcess();
this.cleanupLocalNodes();
this.emitStatus({ phase: 'idle', message: 'Server stopped' });
}

Expand Down Expand Up @@ -249,6 +266,7 @@ export class EngineLocal extends EngineBackend {
*/
async dispose(): Promise<void> {
if (this.child) await this.stopProcess();
this.cleanupLocalNodes();
}

// =========================================================================
Expand Down Expand Up @@ -457,4 +475,96 @@ export class EngineLocal extends EngineBackend {
},
};
}

// =========================================================================
// LOCAL NODES — workspace-adjacent nodes/ folder symlinked into the
// engine catalog so devs can prototype without touching the installed
// engine. The installed engine scans <engineDir>/nodes/<node>/; we
// mirror each <workspace>/nodes/<node>/ in there.
// =========================================================================

/**
* Mirrors each <workspaceRoot>/nodes/<node>/ (with services.json) into
* <engineDir>/nodes/<node>/ via symlink. Skips name collisions with
* built-ins and links already claimed by another window. Stale links
* from crashed runs are refreshed.
*/
private setupLocalNodes(engineDir: string): void {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) return;

const localNodesDir = path.join(workspaceRoot, 'nodes');
if (!fs.existsSync(localNodesDir)) return;

if (!vscode.workspace.isTrusted) {
this.logger.output(`${icons.info} Local nodes folder detected at ${localNodesDir} but workspace is not trusted; ignored`);
return;
}

const targetRoot = path.join(engineDir, 'nodes');
try {
fs.mkdirSync(targetRoot, { recursive: true });
} catch (err) {
this.logger.output(`${icons.warning} Could not prepare local node target dir ${targetRoot}: ${err instanceof Error ? err.message : String(err)}`);
return;
}

let entries: fs.Dirent[];
try {
entries = fs.readdirSync(localNodesDir, { withFileTypes: true });
} catch (err) {
this.logger.output(`${icons.warning} Could not read ${localNodesDir}: ${err instanceof Error ? err.message : String(err)}`);
return;
}

for (const entry of entries) {
if (!entry.isDirectory()) continue;
const sourceDir = path.join(localNodesDir, entry.name);
if (!fs.existsSync(path.join(sourceDir, 'services.json'))) continue;
const targetDir = path.join(targetRoot, entry.name);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
const existing = fs.lstatSync(targetDir);
if (!existing.isSymbolicLink()) {
this.logger.output(`${icons.warning} Local node "${entry.name}" collides with an existing engine node; skipped`);
continue;
}
let currentTarget = '';
try { currentTarget = path.resolve(targetRoot, fs.readlinkSync(targetDir)); } catch { /* unreadable link */ }
if (currentTarget === sourceDir) {
// already linked to our source — reuse
this.linkedNodes.push(targetDir);
continue;
}
if (currentTarget && fs.existsSync(currentTarget)) {
this.logger.output(`${icons.warning} Local node "${entry.name}" target already linked to ${currentTarget}; skipped to avoid clobbering another window`);
continue;
}
// stale: target gone — refresh
fs.unlinkSync(targetDir);
} catch {
// target does not exist — proceed to create
}

try {
fs.symlinkSync(sourceDir, targetDir, 'dir');
this.linkedNodes.push(targetDir);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.output(`${icons.warning} Could not link local node "${entry.name}" (${msg}); skipped. On Windows, enable Developer Mode or run VS Code as administrator.`);
}
}

if (this.linkedNodes.length) {
this.logger.output(`${icons.success} Local nodes registered (${this.linkedNodes.length}): ${this.linkedNodes.map((p) => path.basename(p)).join(', ')}`);
}
}

/** Removes symlinks created by setupLocalNodes. Safe to call multiple times. */
private cleanupLocalNodes(): void {
for (const p of this.linkedNodes) {
try { if (fs.lstatSync(p).isSymbolicLink()) fs.unlinkSync(p); } catch { /* already gone */ }
}
this.linkedNodes = [];
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
30 changes: 30 additions & 0 deletions docs/README-nodes.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,36 @@ The `core` module provides built-in connectors for OneDrive, SharePoint, Google

---

## Local Nodes for Prototyping

To try out a new node without adding it to this repo, create a folder called `nodes/` at the root of your VS Code / Cursor workspace and drop the node in there. The extension picks it up automatically and exposes it to the engine.

### Quick start

Use the same node structure as built-in nodes (see [Adding a New Node](#adding-a-new-node)) — `services.json`, Python interfaces, SVG icon, optional `requirements.txt` — but place the folder in `<workspace>/nodes/<node_name>/` instead of `nodes/src/nodes/`. Each node stays **self-contained** in its own directory.

Open the workspace in VS Code / Cursor, mark it as **trusted** (the standard Workspace Trust prompt), and start the engine. You should see in the engine output channel:

```text
✅ Local nodes registered (1): hello_world
```

Reference the node from your `.pipe` by its protocol just like any built-in. After any change to the node files, run `Ctrl+Shift+P` (`Cmd+Shift+P` on macOS) → **Developer: Reload Window** to pick them up.

### Safety

- **Workspace Trust** is required. In an untrusted workspace the local `nodes/` folder is logged and ignored.
- **Built-ins are never overwritten.** If a local node's directory name collides with an installed engine node, the local one is skipped and a warning is logged.

### Promoting a local node to a built-in

When the node is ready to ship:

1. Move the folder from `<workspace>/nodes/<your_node>/` to this repo's `nodes/src/nodes/<your_node>/`.
2. Open a pull request following [CONTRIBUTING.md](../CONTRIBUTING.md) (fork, branch, PR against `develop`). The folder ships as a unit.

---

## License

MIT License -- see [LICENSE](../LICENSE).
Loading