Skip to content

Commit 247df1f

Browse files
dantelexcursoragent
andcommitted
v0.6.1: connect serve — config-driven multi-service expose
New command: `connect serve` reads the `expose:` block from pconnect.yml and exposes multiple local services with one command. One config file, one command, multiple tunnels — for webhooks, demos, and APIs. - Extended pconnect.yml config format with `expose:` section - New `connect serve` command (expose-side counterpart to `connect dev`) - Updated YAML/JSON parser to handle expose block - Docs: config-driven-expose-webhooks-demos.md - Updated README, DETAILED.md with serve references Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d0264a8 commit 247df1f

File tree

9 files changed

+465
-12
lines changed

9 files changed

+465
-12
lines changed

DETAILED.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ connect delete <service> # Delete a service
216216
connect proxy # Subdomain proxy (my-api.localhost:3000)
217217
connect daemon <action> # Background daemon (install|status|logs)
218218
connect dev # Project dev mode (pconnect.yml)
219+
connect serve # Expose all services from pconnect.yml
219220
connect dns <action> # Local DNS (*.connect domains)
220221
connect mcp <action> # AI assistant integration
221222
connect broker <action> # Agent Permission Broker
@@ -396,6 +397,37 @@ connect dev --init # Create config
396397
connect dev # Connect all services
397398
```
398399

400+
### Config-driven Expose (`connect serve`)
401+
402+
Add an `expose` block to your `pconnect.yml` to expose multiple local services with one command:
403+
404+
```yaml
405+
# pconnect.yml
406+
services:
407+
- name: staging-db
408+
port: 5432
409+
410+
expose:
411+
web:
412+
target: localhost:3000
413+
public: true
414+
api:
415+
target: localhost:8000
416+
webhooks:
417+
target: localhost:3000
418+
public: true
419+
```
420+
421+
Then:
422+
423+
```bash
424+
connect serve # Expose all entries under expose:
425+
```
426+
427+
Each entry becomes a named service in the hub. Set `public: true` to get a public URL (for webhooks and demos). Teammates can `connect reach web` or `connect reach api` to access your services.
428+
429+
See [docs/config-driven-expose-webhooks-demos.md](docs/config-driven-expose-webhooks-demos.md) for the full guide.
430+
399431
### Subdomain Proxy
400432

401433
```bash

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ npx private-connect test db.internal:5432
4949
| Quick tunnel (no signup) | `npx private-connect tunnel 3000` |
5050
| Named tunnel (webhook/demo) | `npx private-connect stripe 3000` |
5151
| Expose a service | `connect 5432` |
52+
| Expose many from config | `connect serve` |
5253
| Access a service | `connect prod-db` |
5354
| Share with a teammate | `connect 5432 --share` |
5455
| Clone a teammate's setup | `connect clone alice` |
@@ -147,6 +148,7 @@ curl -s -X POST http://localhost:3001/v1/ask -H "Content-Type: application/json"
147148
- **DB + Cursor (2 min)**: [docs/database-and-cursor.md](docs/database-and-cursor.md) — expose local DB, reach from anywhere, use with Cursor
148149
- **Docs**: [DETAILED.md](DETAILED.md) — full CLI reference, all features
149150
- **API Reference**: [DETAILED.md#control-api](DETAILED.md#control-api) — REST API documentation
151+
- **Config-driven expose**: [docs/config-driven-expose-webhooks-demos.md](docs/config-driven-expose-webhooks-demos.md) — expose multiple services from config (`connect serve`)
150152
- **Debugging**: [docs/debugging.md](docs/debugging.md) — live traffic inspection, AI copilot
151153
- **AI & MCP**: [docs/AI.md](docs/AI.md) — AI integration, orchestration, SDK
152154
- **OpenClaw**: [docs/openclaw-remote-access.md](docs/openclaw-remote-access.md) — secure remote access to OpenClaw gateway

apps/agent/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "agent",
3-
"version": "0.6.0",
3+
"version": "0.6.1",
44
"private": true,
55
"bin": {
66
"connect": "./dist/cli.js"

apps/agent/pconnect.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
# Private Connect project config
2-
# Run: connect dev
2+
# Reach services: connect dev
3+
# Expose services: connect serve
34

45
services:
56
- name: staging-db
67
port: 5432
78
- name: redis
89
port: 6379
10+
11+
# Expose local services (webhooks, demos, APIs)
12+
# Uncomment and edit, then run: connect serve
13+
#
14+
# expose:
15+
# web:
16+
# target: localhost:3000
17+
# public: true
18+
# api:
19+
# target: localhost:8000

apps/agent/src/cli.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { joinCommand } from './commands/join';
1414
import { mapCommand, mapStatusCommand } from './commands/map';
1515
import { daemonCommand } from './commands/daemon';
1616
import { devCommand, devInitCommand } from './commands/dev';
17+
import { serveCommand } from './commands/serve';
1718
import { linkCommand } from './commands/link';
1819
import { deleteCommand } from './commands/delete';
1920
import { doctorCommand, cleanupCommand, statusCommand } from './commands/doctor';
@@ -434,6 +435,18 @@ program
434435
}
435436
});
436437

438+
// Serve Mode Commands
439+
program
440+
.command('serve')
441+
.description('Expose all services defined in the expose block of pconnect.yml')
442+
.option('-H, --hub <url>', 'Hub URL', DEFAULT_HUB_URL)
443+
.option('-f, --file <path>', 'Path to pconnect.yml file')
444+
.option('-c, --config <path>', 'Agent config file path')
445+
.action((options) => {
446+
if (options.config) setConfigPath(options.config);
447+
serveCommand(options);
448+
});
449+
437450
// Health & Diagnostics Commands
438451
program
439452
.command('doctor')

apps/agent/src/commands/dev.ts

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,23 @@ interface DevOptions {
1313
config?: string;
1414
}
1515

16-
interface ProjectService {
16+
export interface ProjectService {
1717
name: string;
1818
port?: number;
1919
localPort?: number; // alias for port
2020
protocol?: string;
2121
}
2222

23-
interface ProjectConfig {
23+
export interface ExposeEntry {
24+
name: string;
25+
target: string;
26+
public?: boolean;
27+
expires?: string;
28+
}
29+
30+
export interface ProjectConfig {
2431
services: ProjectService[];
32+
expose?: ExposeEntry[];
2533
hub?: string;
2634
}
2735

@@ -34,7 +42,7 @@ const CONFIG_FILENAMES = [
3442
'.pconnect.json',
3543
];
3644

37-
function findProjectConfig(startDir?: string): string | null {
45+
export function findProjectConfig(startDir?: string): string | null {
3846
const dir = startDir || process.cwd();
3947

4048
for (const filename of CONFIG_FILENAMES) {
@@ -56,17 +64,28 @@ function findProjectConfig(startDir?: string): string | null {
5664
function parseYaml(content: string): ProjectConfig {
5765
// Simple YAML parser for our limited use case
5866
const lines = content.split('\n');
59-
const config: ProjectConfig = { services: [] };
67+
const config: ProjectConfig = { services: [], expose: [] };
6068
let currentService: ProjectService | null = null;
61-
let inServices = false;
69+
let currentExposeEntry: ExposeEntry | null = null;
70+
let section: 'none' | 'services' | 'expose' = 'none';
6271

6372
for (const line of lines) {
6473
const trimmed = line.trim();
6574

6675
if (trimmed.startsWith('#') || trimmed === '') continue;
6776

77+
// Top-level section headers
6878
if (trimmed === 'services:') {
69-
inServices = true;
79+
if (currentService) { config.services.push(currentService); currentService = null; }
80+
if (currentExposeEntry && currentExposeEntry.target) { config.expose!.push(currentExposeEntry); currentExposeEntry = null; }
81+
section = 'services';
82+
continue;
83+
}
84+
85+
if (trimmed === 'expose:') {
86+
if (currentService) { config.services.push(currentService); currentService = null; }
87+
if (currentExposeEntry && currentExposeEntry.target) { config.expose!.push(currentExposeEntry); currentExposeEntry = null; }
88+
section = 'expose';
7089
continue;
7190
}
7291

@@ -75,7 +94,7 @@ function parseYaml(content: string): ProjectConfig {
7594
continue;
7695
}
7796

78-
if (inServices) {
97+
if (section === 'services') {
7998
// New service entry
8099
if (trimmed.startsWith('- name:')) {
81100
if (currentService) {
@@ -94,21 +113,59 @@ function parseYaml(content: string): ProjectConfig {
94113
}
95114
}
96115
}
116+
117+
if (section === 'expose') {
118+
// New expose entry: "name:" (word followed by colon, no value)
119+
const entryMatch = trimmed.match(/^([a-zA-Z0-9_-]+):$/);
120+
if (entryMatch) {
121+
if (currentExposeEntry && currentExposeEntry.target) {
122+
config.expose!.push(currentExposeEntry);
123+
}
124+
currentExposeEntry = { name: entryMatch[1], target: '' };
125+
} else if (currentExposeEntry) {
126+
// Expose entry properties
127+
if (trimmed.startsWith('target:')) {
128+
currentExposeEntry.target = trimmed.split(':').slice(1).join(':').trim().replace(/['"]/g, '');
129+
} else if (trimmed.startsWith('public:')) {
130+
const val = trimmed.split(':')[1].trim().toLowerCase();
131+
currentExposeEntry.public = val === 'true';
132+
} else if (trimmed.startsWith('expires:')) {
133+
currentExposeEntry.expires = trimmed.split(':')[1].trim().replace(/['"]/g, '');
134+
}
135+
}
136+
}
97137
}
98138

139+
// Flush remaining entries
99140
if (currentService) {
100141
config.services.push(currentService);
101142
}
143+
if (currentExposeEntry && currentExposeEntry.target) {
144+
config.expose!.push(currentExposeEntry);
145+
}
102146

103147
return config;
104148
}
105149

106-
function loadProjectConfig(configPath: string): ProjectConfig | null {
150+
export function loadProjectConfig(configPath: string): ProjectConfig | null {
107151
try {
108152
const content = fs.readFileSync(configPath, 'utf-8');
109153

110154
if (configPath.endsWith('.json')) {
111-
return JSON.parse(content) as ProjectConfig;
155+
const raw = JSON.parse(content);
156+
const config: ProjectConfig = {
157+
services: raw.services || [],
158+
hub: raw.hub,
159+
expose: [],
160+
};
161+
// Convert expose map { name: { target, public, expires } } to array
162+
if (raw.expose && typeof raw.expose === 'object') {
163+
for (const [name, entry] of Object.entries(raw.expose)) {
164+
const e = entry as { target: string; public?: boolean; expires?: string };
165+
config.expose!.push({ name, target: e.target, public: e.public, expires: e.expires });
166+
}
167+
}
168+
return config;
112169
} else {
113170
return parseYaml(content);
114171
}

apps/agent/src/commands/serve.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import chalk from 'chalk';
2+
import { loadConfig } from '../config';
3+
import { exposeCommand } from './expose';
4+
import { findProjectConfig, loadProjectConfig } from './dev';
5+
6+
interface ServeOptions {
7+
hub: string;
8+
file?: string;
9+
config?: string;
10+
}
11+
12+
/**
13+
* connect serve - Expose all services defined in the expose block of pconnect.yml
14+
*
15+
* Reads the `expose:` section from the project config and calls exposeCommand
16+
* for each entry. One command, multiple tunnels.
17+
*/
18+
export async function serveCommand(options: ServeOptions) {
19+
const agentConfig = loadConfig();
20+
21+
if (!agentConfig) {
22+
console.error(chalk.red('\n[x] Agent not configured'));
23+
console.log(chalk.gray(` Run ${chalk.cyan('connect up')} first to authenticate.\n`));
24+
process.exit(1);
25+
}
26+
27+
// Find project config
28+
const configPath = options.file || findProjectConfig();
29+
30+
if (!configPath) {
31+
console.log(chalk.yellow('\n[!] No project config found.\n'));
32+
console.log(chalk.gray(' Create a pconnect.yml with an expose section:\n'));
33+
console.log(chalk.cyan(' expose:'));
34+
console.log(chalk.cyan(' web:'));
35+
console.log(chalk.cyan(' target: localhost:3000'));
36+
console.log(chalk.cyan(' public: true'));
37+
console.log(chalk.cyan(' api:'));
38+
console.log(chalk.cyan(' target: localhost:8000'));
39+
console.log();
40+
process.exit(1);
41+
}
42+
43+
const projectConfig = loadProjectConfig(configPath);
44+
45+
if (!projectConfig || !projectConfig.expose || projectConfig.expose.length === 0) {
46+
console.error(chalk.red('\n[x] No expose entries in config.\n'));
47+
console.log(chalk.gray(' Add an expose section to your pconnect.yml:\n'));
48+
console.log(chalk.cyan(' expose:'));
49+
console.log(chalk.cyan(' web:'));
50+
console.log(chalk.cyan(' target: localhost:3000'));
51+
console.log(chalk.cyan(' public: true'));
52+
console.log();
53+
process.exit(1);
54+
}
55+
56+
const hubUrl = projectConfig.hub || agentConfig.hubUrl || options.hub;
57+
const entries = projectConfig.expose;
58+
59+
console.log(chalk.cyan('\n📡 Private Connect Serve\n'));
60+
console.log(chalk.gray(` Config: ${configPath}`));
61+
console.log(chalk.gray(` Hub: ${hubUrl}`));
62+
console.log(chalk.gray(` Services: ${entries.length}`));
63+
console.log();
64+
65+
// Expose each entry
66+
const results: Array<{ name: string; target: string; success: boolean; public?: boolean }> = [];
67+
68+
for (const entry of entries) {
69+
if (!entry.target) {
70+
console.log(chalk.yellow(` [!] Skipping "${entry.name}": no target specified`));
71+
results.push({ name: entry.name, target: '', success: false });
72+
continue;
73+
}
74+
75+
console.log(chalk.gray(` ── ${entry.name} ──\n`));
76+
77+
try {
78+
const result = await exposeCommand(entry.target, {
79+
name: entry.name,
80+
hub: hubUrl,
81+
protocol: 'auto',
82+
public: entry.public || false,
83+
});
84+
85+
results.push({
86+
name: entry.name,
87+
target: entry.target,
88+
success: !!result,
89+
public: entry.public,
90+
});
91+
} catch (error) {
92+
const err = error as Error;
93+
console.log(chalk.red(` [x] Failed to expose "${entry.name}": ${err.message}`));
94+
results.push({ name: entry.name, target: entry.target, success: false });
95+
}
96+
97+
console.log();
98+
}
99+
100+
// Print summary
101+
const successful = results.filter(r => r.success);
102+
const failed = results.filter(r => !r.success);
103+
104+
console.log(chalk.cyan('─────────────────────────────────────'));
105+
console.log(chalk.white(' Serve Summary\n'));
106+
107+
if (successful.length > 0) {
108+
console.log(chalk.green(` ✓ ${successful.length} service(s) exposed:\n`));
109+
successful.forEach(r => {
110+
const publicTag = r.public ? chalk.blue(' [public]') : chalk.gray(' [private]');
111+
console.log(chalk.white(` ${r.name}`) + chalk.gray(` → ${r.target}`) + publicTag);
112+
});
113+
console.log();
114+
}
115+
116+
if (failed.length > 0) {
117+
console.log(chalk.yellow(` ! ${failed.length} service(s) failed:\n`));
118+
failed.forEach(r => {
119+
console.log(chalk.gray(` ${r.name}${r.target || '(no target)'}`));
120+
});
121+
console.log();
122+
}
123+
124+
if (successful.length === 0) {
125+
console.error(chalk.red(' [x] No services exposed.\n'));
126+
process.exit(1);
127+
}
128+
129+
console.log(chalk.gray(' Press Ctrl+C to stop all services\n'));
130+
131+
// Keep process alive (WebSocket connections from exposeCommand keep the event loop active,
132+
// but this ensures we don't exit even if all connections temporarily drop)
133+
await new Promise(() => {});
134+
}

0 commit comments

Comments
 (0)