@hienlh/ppm 0.1.6 → 0.2.0

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 (29) hide show
  1. package/bun.lock +52 -0
  2. package/dist/web/assets/{chat-tab-CNXjLOhI.js → chat-tab-D7dR7kbZ.js} +1 -1
  3. package/dist/web/assets/{code-editor-tGMPwYNs.js → code-editor-r8P6Gk4M.js} +1 -1
  4. package/dist/web/assets/{diff-viewer-B4A8pPbo.js → diff-viewer-vSvrem_i.js} +1 -1
  5. package/dist/web/assets/{git-graph-ODjrGZOQ.js → git-graph-Cn-s1k0-.js} +1 -1
  6. package/dist/web/assets/{git-status-panel-B0Im1hrU.js → git-status-panel-QjAQzNAi.js} +1 -1
  7. package/dist/web/assets/index-DUBI96T5.css +2 -0
  8. package/dist/web/assets/{index-D2STBl88.js → index-nk1dAWff.js} +2 -2
  9. package/dist/web/assets/{project-list-VjQQcU3X.js → project-list-DqiatpaH.js} +1 -1
  10. package/dist/web/assets/{settings-tab-ChhdL0EG.js → settings-tab-iCGeFFdt.js} +1 -1
  11. package/dist/web/index.html +2 -2
  12. package/dist/web/sw.js +1 -1
  13. package/docs/code-standards.md +74 -0
  14. package/docs/codebase-summary.md +11 -7
  15. package/docs/deployment-guide.md +81 -11
  16. package/docs/project-overview-pdr.md +62 -2
  17. package/docs/system-architecture.md +24 -8
  18. package/package.json +3 -1
  19. package/src/cli/commands/init.ts +186 -43
  20. package/src/cli/commands/status.ts +73 -0
  21. package/src/cli/commands/stop.ts +24 -10
  22. package/src/index.ts +28 -5
  23. package/src/server/index.ts +104 -15
  24. package/src/services/cloudflared.service.ts +99 -0
  25. package/src/services/tunnel.service.ts +100 -0
  26. package/src/web/app.tsx +2 -2
  27. package/src/web/components/auth/login-screen.tsx +1 -1
  28. package/src/web/styles/globals.css +4 -3
  29. package/dist/web/assets/index-BePIZMuy.css +0 -2
@@ -1,57 +1,200 @@
1
1
  import { resolve } from "node:path";
2
2
  import { homedir } from "node:os";
3
3
  import { existsSync } from "node:fs";
4
+ import { input, confirm, select, password } from "@inquirer/prompts";
4
5
  import { configService } from "../../services/config.service.ts";
5
6
  import { projectService } from "../../services/project.service.ts";
6
7
 
7
- export async function initProject() {
8
- const ppmDir = resolve(homedir(), ".ppm");
9
- const globalConfig = resolve(ppmDir, "config.yaml");
8
+ const DEFAULT_PORT = 3210;
10
9
 
11
- // Load existing or create default
12
- configService.load();
13
- console.log(`Config: ${configService.getConfigPath()}`);
14
-
15
- // Scan CWD for git repos
16
- const cwd = process.cwd();
17
- console.log(`\nScanning ${cwd} for git repositories...`);
18
- const repos = projectService.scanForGitRepos(cwd);
19
-
20
- if (repos.length === 0) {
21
- console.log("No git repositories found.");
22
- } else {
23
- console.log(`Found ${repos.length} git repo(s):\n`);
24
- const existing = configService.get("projects");
25
-
26
- let added = 0;
27
- for (const repoPath of repos) {
28
- const name = repoPath.split("/").pop() ?? "unknown";
29
- const alreadyRegistered = existing.some(
30
- (p) => resolve(p.path) === repoPath || p.name === name,
31
- );
32
-
33
- if (alreadyRegistered) {
34
- console.log(` [skip] ${name} (${repoPath}) already registered`);
35
- continue;
36
- }
37
-
38
- try {
39
- projectService.add(repoPath, name);
40
- console.log(` [added] ${name} (${repoPath})`);
41
- added++;
42
- } catch (e) {
43
- console.log(` [error] ${name}: ${(e as Error).message}`);
44
- }
10
+ export interface InitOptions {
11
+ port?: string;
12
+ scan?: string;
13
+ auth?: boolean;
14
+ password?: string;
15
+ share?: boolean;
16
+ /** Skip prompts, use defaults + flags only (for VPS/scripts) */
17
+ yes?: boolean;
18
+ }
19
+
20
+ /** Check if config already exists */
21
+ export function hasConfig(): boolean {
22
+ const globalConfig = resolve(homedir(), ".ppm", "config.yaml");
23
+ const localConfig = resolve(process.cwd(), "ppm.yaml");
24
+ return existsSync(globalConfig) || existsSync(localConfig);
25
+ }
26
+
27
+ export async function initProject(options: InitOptions = {}) {
28
+ const nonInteractive = options.yes ?? false;
29
+
30
+ // Check if already initialized
31
+ if (hasConfig() && !nonInteractive) {
32
+ const overwrite = await confirm({
33
+ message: "PPM is already configured. Re-initialize? (this will overwrite your config)",
34
+ default: false,
35
+ });
36
+ if (!overwrite) {
37
+ console.log(" Cancelled.");
38
+ return;
45
39
  }
40
+ }
41
+
42
+ console.log("\n 🔧 PPM Setup\n");
46
43
 
47
- console.log(`\nAdded ${added} project(s).`);
44
+ // 1. Port
45
+ const portValue = options.port
46
+ ? parseInt(options.port, 10)
47
+ : nonInteractive
48
+ ? DEFAULT_PORT
49
+ : parseInt(await input({
50
+ message: "Port:",
51
+ default: String(DEFAULT_PORT),
52
+ validate: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536 ? true : "Enter valid port (1-65535)",
53
+ }), 10);
54
+
55
+ // 2. Scan directory
56
+ const scanDir = options.scan
57
+ ?? (nonInteractive
58
+ ? homedir()
59
+ : await input({
60
+ message: "Projects directory to scan:",
61
+ default: homedir(),
62
+ }));
63
+
64
+ // 3. Auth
65
+ const authEnabled = options.auth
66
+ ?? (nonInteractive
67
+ ? true
68
+ : await confirm({
69
+ message: "Enable authentication?",
70
+ default: true,
71
+ }));
72
+
73
+ // 4. Password (if auth enabled)
74
+ let authToken = "";
75
+ if (authEnabled) {
76
+ authToken = options.password
77
+ ?? (nonInteractive
78
+ ? generateToken()
79
+ : await password({
80
+ message: "Set access password (leave empty to auto-generate):",
81
+ }));
82
+ if (!authToken) authToken = generateToken();
48
83
  }
49
84
 
50
- const auth = configService.get("auth");
51
- console.log(`\nAuth: ${auth.enabled ? "enabled" : "disabled"}`);
52
- if (auth.enabled) {
53
- console.log(`Token: ${auth.token}`);
85
+ // 5. Share (install cloudflared)
86
+ const wantShare = options.share
87
+ ?? (nonInteractive
88
+ ? false
89
+ : await confirm({
90
+ message: "Install cloudflared for public sharing (--share)?",
91
+ default: false,
92
+ }));
93
+
94
+ // 6. Advanced settings
95
+ let aiModel = "claude-sonnet-4-6";
96
+ let aiEffort: "low" | "medium" | "high" | "max" = "high";
97
+ let aiMaxTurns = 100;
98
+ let aiApiKeyEnv = "ANTHROPIC_API_KEY";
99
+
100
+ if (!nonInteractive) {
101
+ const wantAdvanced = await confirm({
102
+ message: "Configure advanced AI settings?",
103
+ default: false,
104
+ });
105
+
106
+ if (wantAdvanced) {
107
+ aiModel = await select({
108
+ message: "AI model:",
109
+ choices: [
110
+ { value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (fast, recommended)" },
111
+ { value: "claude-opus-4-6", name: "Claude Opus 4.6 (powerful)" },
112
+ { value: "claude-haiku-4-5", name: "Claude Haiku 4.5 (cheap)" },
113
+ ],
114
+ default: "claude-sonnet-4-6",
115
+ });
116
+
117
+ aiEffort = await select({
118
+ message: "Thinking effort:",
119
+ choices: [
120
+ { value: "low" as const, name: "Low" },
121
+ { value: "medium" as const, name: "Medium" },
122
+ { value: "high" as const, name: "High (recommended)" },
123
+ { value: "max" as const, name: "Max" },
124
+ ],
125
+ default: "high" as const,
126
+ });
127
+
128
+ aiMaxTurns = parseInt(await input({
129
+ message: "Max turns per chat:",
130
+ default: "100",
131
+ validate: (v) => /^\d+$/.test(v) && +v >= 1 && +v <= 500 ? true : "Enter 1-500",
132
+ }), 10);
133
+
134
+ aiApiKeyEnv = await input({
135
+ message: "API key env variable:",
136
+ default: "ANTHROPIC_API_KEY",
137
+ });
138
+ }
139
+ }
140
+
141
+ // Apply config
142
+ configService.load();
143
+ configService.set("port", portValue);
144
+ configService.set("auth", { enabled: authEnabled, token: authToken });
145
+ configService.set("ai", {
146
+ default_provider: "claude",
147
+ providers: {
148
+ claude: {
149
+ type: "agent-sdk",
150
+ api_key_env: aiApiKeyEnv,
151
+ model: aiModel,
152
+ effort: aiEffort,
153
+ max_turns: aiMaxTurns,
154
+ },
155
+ },
156
+ });
157
+ configService.save();
158
+
159
+ // Scan for projects
160
+ console.log(`\n Scanning ${scanDir} for git repositories...`);
161
+ const repos = projectService.scanForGitRepos(scanDir);
162
+ const existing = configService.get("projects");
163
+ let added = 0;
164
+
165
+ for (const repoPath of repos) {
166
+ const name = repoPath.split("/").pop() ?? "unknown";
167
+ if (existing.some((p) => resolve(p.path) === repoPath || p.name === name)) continue;
168
+ try {
169
+ projectService.add(repoPath, name);
170
+ added++;
171
+ } catch {}
172
+ }
173
+ console.log(` Found ${repos.length} repo(s), added ${added} new project(s).`);
174
+
175
+ // Install cloudflared if requested
176
+ if (wantShare) {
177
+ console.log("\n Installing cloudflared...");
178
+ const { ensureCloudflared } = await import("../../services/cloudflared.service.ts");
179
+ await ensureCloudflared();
180
+ console.log(" ✓ cloudflared ready");
54
181
  }
55
182
 
56
- console.log(`\nRun "ppm start" to start the server.`);
183
+ // 8. Next steps
184
+ console.log(`\n ✓ Config saved to ${configService.getConfigPath()}\n`);
185
+ console.log(" Next steps:");
186
+ console.log(` ppm start # Start (daemon, port ${portValue})`);
187
+ console.log(` ppm start -f # Start in foreground`);
188
+ if (wantShare) {
189
+ console.log(` ppm start --share # Start + public URL`);
190
+ }
191
+ if (authEnabled) {
192
+ console.log(`\n Access password: ${authToken}`);
193
+ }
194
+ console.log();
195
+ }
196
+
197
+ function generateToken(): string {
198
+ const { randomBytes } = require("node:crypto");
199
+ return randomBytes(16).toString("hex");
57
200
  }
@@ -0,0 +1,73 @@
1
+ import { resolve } from "node:path";
2
+ import { homedir } from "node:os";
3
+ import { readFileSync, existsSync } from "node:fs";
4
+
5
+ const STATUS_FILE = resolve(homedir(), ".ppm", "status.json");
6
+ const PID_FILE = resolve(homedir(), ".ppm", "ppm.pid");
7
+
8
+ interface DaemonStatus {
9
+ running: boolean;
10
+ pid: number | null;
11
+ port: number | null;
12
+ host: string | null;
13
+ shareUrl: string | null;
14
+ }
15
+
16
+ function getDaemonStatus(): DaemonStatus {
17
+ const notRunning: DaemonStatus = { running: false, pid: null, port: null, host: null, shareUrl: null };
18
+
19
+ // Try status.json first
20
+ if (existsSync(STATUS_FILE)) {
21
+ try {
22
+ const data = JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
23
+ const pid = data.pid as number;
24
+ // Check if process is actually alive
25
+ try {
26
+ process.kill(pid, 0); // signal 0 = check existence
27
+ return { running: true, pid, port: data.port, host: data.host, shareUrl: data.shareUrl ?? null };
28
+ } catch {
29
+ return notRunning; // stale status file
30
+ }
31
+ } catch {
32
+ return notRunning;
33
+ }
34
+ }
35
+
36
+ // Fallback to ppm.pid
37
+ if (existsSync(PID_FILE)) {
38
+ try {
39
+ const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
40
+ process.kill(pid, 0);
41
+ return { running: true, pid, port: null, host: null, shareUrl: null };
42
+ } catch {
43
+ return notRunning;
44
+ }
45
+ }
46
+
47
+ return notRunning;
48
+ }
49
+
50
+ export async function showStatus(options: { json?: boolean }) {
51
+ const status = getDaemonStatus();
52
+
53
+ if (options.json) {
54
+ console.log(JSON.stringify(status));
55
+ return;
56
+ }
57
+
58
+ if (!status.running) {
59
+ console.log(" PPM is not running.");
60
+ return;
61
+ }
62
+
63
+ console.log(`\n PPM daemon is running\n`);
64
+ console.log(` PID: ${status.pid}`);
65
+ if (status.port) console.log(` Local: http://localhost:${status.port}/`);
66
+ if (status.shareUrl) {
67
+ console.log(` Share: ${status.shareUrl}`);
68
+ const qr = await import("qrcode-terminal");
69
+ console.log();
70
+ qr.generate(status.shareUrl, { small: true });
71
+ }
72
+ console.log();
73
+ }
@@ -3,29 +3,43 @@ import { homedir } from "node:os";
3
3
  import { readFileSync, unlinkSync, existsSync } from "node:fs";
4
4
 
5
5
  const PID_FILE = resolve(homedir(), ".ppm", "ppm.pid");
6
+ const STATUS_FILE = resolve(homedir(), ".ppm", "status.json");
6
7
 
7
8
  export async function stopServer() {
8
- if (!existsSync(PID_FILE)) {
9
- console.log("No PPM daemon running (PID file not found).");
10
- return;
9
+ let pid: number | null = null;
10
+
11
+ // Try status.json first (new format)
12
+ if (existsSync(STATUS_FILE)) {
13
+ try {
14
+ const status = JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
15
+ pid = status.pid;
16
+ } catch {}
17
+ }
18
+
19
+ // Fallback to ppm.pid (compat)
20
+ if (!pid && existsSync(PID_FILE)) {
21
+ pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
11
22
  }
12
23
 
13
- const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
14
- if (isNaN(pid)) {
15
- console.log("Invalid PID file. Removing it.");
16
- unlinkSync(PID_FILE);
24
+ if (!pid || isNaN(pid)) {
25
+ console.log("No PPM daemon running.");
26
+ // Cleanup stale files
27
+ if (existsSync(STATUS_FILE)) unlinkSync(STATUS_FILE);
28
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
17
29
  return;
18
30
  }
19
31
 
20
32
  try {
21
33
  process.kill(pid);
22
- unlinkSync(PID_FILE);
34
+ if (existsSync(STATUS_FILE)) unlinkSync(STATUS_FILE);
35
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
23
36
  console.log(`PPM daemon stopped (PID: ${pid}).`);
24
37
  } catch (e) {
25
38
  const error = e as NodeJS.ErrnoException;
26
39
  if (error.code === "ESRCH") {
27
- console.log(`Process ${pid} not found. Cleaning up PID file.`);
28
- unlinkSync(PID_FILE);
40
+ console.log(`Process ${pid} not found. Cleaning up stale files.`);
41
+ if (existsSync(STATUS_FILE)) unlinkSync(STATUS_FILE);
42
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
29
43
  } else {
30
44
  console.error(`Failed to stop process ${pid}: ${error.message}`);
31
45
  }
package/src/index.ts CHANGED
@@ -10,11 +10,18 @@ program
10
10
 
11
11
  program
12
12
  .command("start")
13
- .description("Start the PPM server")
13
+ .description("Start the PPM server (background by default)")
14
14
  .option("-p, --port <port>", "Port to listen on")
15
- .option("-d, --daemon", "Run as background daemon")
15
+ .option("-f, --foreground", "Run in foreground (default: background daemon)")
16
+ .option("-d, --daemon", "Run as background daemon (default, kept for compat)")
17
+ .option("-s, --share", "Share via public URL (Cloudflare tunnel)")
16
18
  .option("-c, --config <path>", "Path to config file")
17
19
  .action(async (options) => {
20
+ // Auto-init on first run
21
+ const { hasConfig, initProject } = await import("./cli/commands/init.ts");
22
+ if (!hasConfig()) {
23
+ await initProject();
24
+ }
18
25
  const { startServer } = await import("./server/index.ts");
19
26
  await startServer(options);
20
27
  });
@@ -27,6 +34,15 @@ program
27
34
  await stopServer();
28
35
  });
29
36
 
37
+ program
38
+ .command("status")
39
+ .description("Show PPM daemon status")
40
+ .option("--json", "Output as JSON")
41
+ .action(async (options) => {
42
+ const { showStatus } = await import("./cli/commands/status.ts");
43
+ await showStatus(options);
44
+ });
45
+
30
46
  program
31
47
  .command("open")
32
48
  .description("Open PPM in browser")
@@ -38,10 +54,17 @@ program
38
54
 
39
55
  program
40
56
  .command("init")
41
- .description("Initialize PPM configuration scan for git repos, create config")
42
- .action(async () => {
57
+ .description("Initialize PPM configuration (interactive or via flags)")
58
+ .option("-p, --port <port>", "Port to listen on")
59
+ .option("--scan <path>", "Directory to scan for git repos")
60
+ .option("--auth", "Enable authentication")
61
+ .option("--no-auth", "Disable authentication")
62
+ .option("--password <pw>", "Set access password")
63
+ .option("--share", "Pre-install cloudflared for sharing")
64
+ .option("-y, --yes", "Non-interactive mode (use defaults + flags)")
65
+ .action(async (options) => {
43
66
  const { initProject } = await import("./cli/commands/init.ts");
44
- await initProject();
67
+ await initProject(options);
45
68
  });
46
69
 
47
70
  const { registerProjectsCommands } = await import("./cli/commands/projects.ts");
@@ -32,7 +32,9 @@ app.route("/", staticRoutes);
32
32
 
33
33
  export async function startServer(options: {
34
34
  port?: string;
35
- daemon?: boolean;
35
+ foreground?: boolean;
36
+ daemon?: boolean; // compat, ignored (daemon is now default)
37
+ share?: boolean;
36
38
  config?: string;
37
39
  }) {
38
40
  // Load config
@@ -40,27 +42,66 @@ export async function startServer(options: {
40
42
  const port = parseInt(options.port ?? String(configService.get("port")), 10);
41
43
  const host = configService.get("host");
42
44
 
43
- if (options.daemon) {
44
- // Daemon mode: spawn detached child process, write PID file
45
+ const isDaemon = !options.foreground;
46
+
47
+ if (isDaemon) {
45
48
  const { resolve } = await import("node:path");
46
49
  const { homedir } = await import("node:os");
47
- const { writeFileSync, mkdirSync, existsSync } = await import("node:fs");
50
+ const { writeFileSync, readFileSync, mkdirSync, existsSync } = await import("node:fs");
48
51
 
49
52
  const ppmDir = resolve(homedir(), ".ppm");
50
53
  if (!existsSync(ppmDir)) mkdirSync(ppmDir, { recursive: true });
51
54
  const pidFile = resolve(ppmDir, "ppm.pid");
55
+ const statusFile = resolve(ppmDir, "status.json");
56
+
57
+ // If --share, download cloudflared in parent (shows progress to user)
58
+ if (options.share) {
59
+ const { ensureCloudflared } = await import("../services/cloudflared.service.ts");
60
+ await ensureCloudflared();
61
+ }
52
62
 
63
+ // Spawn child process
53
64
  const child = Bun.spawn({
54
- cmd: ["bun", "run", import.meta.dir + "/index.ts", "__serve__", String(port), host, options.config ?? ""],
65
+ cmd: [
66
+ process.execPath, "run", import.meta.dir + "/index.ts", "__serve__",
67
+ String(port), host, options.config ?? "", options.share ? "share" : "",
68
+ ],
55
69
  stdio: ["ignore", "ignore", "ignore"],
56
70
  env: process.env,
57
71
  });
58
-
59
- // Unref so parent can exit
60
72
  child.unref();
61
73
  writeFileSync(pidFile, String(child.pid));
62
- console.log(`PPM daemon started (PID: ${child.pid}) on http://${host}:${port}`);
63
- console.log(`PID file: ${pidFile}`);
74
+
75
+ // Poll for status.json (child writes it when ready)
76
+ const startTime = Date.now();
77
+ let status: { pid: number; port: number; host: string; shareUrl?: string } | null = null;
78
+ while (Date.now() - startTime < 30_000) {
79
+ if (existsSync(statusFile)) {
80
+ try {
81
+ status = JSON.parse(readFileSync(statusFile, "utf-8"));
82
+ break;
83
+ } catch { /* file not fully written yet */ }
84
+ }
85
+ await Bun.sleep(200);
86
+ }
87
+
88
+ if (status) {
89
+ console.log(`\n PPM daemon started (PID: ${status.pid})\n`);
90
+ console.log(` ➜ Local: http://localhost:${status.port}/`);
91
+ if (status.shareUrl) {
92
+ console.log(` ➜ Share: ${status.shareUrl}`);
93
+ if (!configService.get("auth").enabled) {
94
+ console.log(`\n ⚠ Warning: auth is disabled — your IDE is publicly accessible!`);
95
+ console.log(` Enable auth in ~/.ppm/config.yaml or restart without --share.`);
96
+ }
97
+ const qr = await import("qrcode-terminal");
98
+ console.log();
99
+ qr.generate(status.shareUrl, { small: true });
100
+ }
101
+ } else {
102
+ console.log(`\n PPM daemon started (PID: ${child.pid}) but status not confirmed.`);
103
+ }
104
+
64
105
  process.exit(0);
65
106
  }
66
107
 
@@ -74,7 +115,6 @@ export async function startServer(options: {
74
115
  // WebSocket upgrade: /ws/project/:projectName/terminal/:id
75
116
  if (url.pathname.startsWith("/ws/project/")) {
76
117
  const parts = url.pathname.split("/");
77
- // parts: ["", "ws", "project", projectName, type, id]
78
118
  const projectName = parts[3] ?? "";
79
119
  const wsType = parts[4] ?? "";
80
120
  const id = parts[5] ?? "";
@@ -97,11 +137,10 @@ export async function startServer(options: {
97
137
  }
98
138
  }
99
139
 
100
- // Fall through to Hono for all other requests
101
140
  return app.fetch(req, server);
102
141
  },
103
142
  websocket: {
104
- idleTimeout: 960, // 16 minutes — keepalive ping handles liveness
143
+ idleTimeout: 960,
105
144
  sendPong: true,
106
145
  open(ws: any) {
107
146
  if (ws.data?.type === "chat") chatWebSocket.open(ws);
@@ -118,10 +157,9 @@ export async function startServer(options: {
118
157
  } as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
119
158
  });
120
159
 
121
- console.log(`\n PPM v0.1.6 ready\n`);
160
+ console.log(`\n PPM v0.2.0 ready\n`);
122
161
  console.log(` ➜ Local: http://localhost:${server.port}/`);
123
162
 
124
- // List all network interfaces
125
163
  const { networkInterfaces } = await import("node:os");
126
164
  const nets = networkInterfaces();
127
165
  for (const name of Object.keys(nets)) {
@@ -132,6 +170,26 @@ export async function startServer(options: {
132
170
  }
133
171
  }
134
172
 
173
+ // Share tunnel in foreground mode
174
+ if (options.share) {
175
+ try {
176
+ const { tunnelService } = await import("../services/tunnel.service.ts");
177
+ console.log("\n Starting share tunnel...");
178
+ const shareUrl = await tunnelService.startTunnel(server.port);
179
+ console.log(` ➜ Share: ${shareUrl}`);
180
+ if (!configService.get("auth").enabled) {
181
+ console.log(`\n ⚠ Warning: auth is disabled — your IDE is publicly accessible!`);
182
+ console.log(` Enable auth in ~/.ppm/config.yaml or restart without --share.`);
183
+ }
184
+ const qr = await import("qrcode-terminal");
185
+ console.log();
186
+ qr.generate(shareUrl, { small: true });
187
+ } catch (err: unknown) {
188
+ const msg = err instanceof Error ? err.message : String(err);
189
+ console.error(` ✗ Share failed: ${msg}`);
190
+ }
191
+ }
192
+
135
193
  console.log(`\n Auth: ${configService.get("auth").enabled ? "enabled" : "disabled"}`);
136
194
  if (configService.get("auth").enabled) {
137
195
  console.log(` Token: ${configService.get("auth").token}`);
@@ -145,16 +203,23 @@ if (process.argv.includes("__serve__")) {
145
203
  const port = parseInt(process.argv[idx + 1] ?? "8080", 10);
146
204
  const host = process.argv[idx + 2] ?? "0.0.0.0";
147
205
  const configPath = process.argv[idx + 3] || undefined;
206
+ const shareFlag = process.argv[idx + 4] === "share";
148
207
 
149
208
  configService.load(configPath);
150
209
 
210
+ const { resolve } = await import("node:path");
211
+ const { homedir } = await import("node:os");
212
+ const { writeFileSync, unlinkSync } = await import("node:fs");
213
+
214
+ const statusFile = resolve(homedir(), ".ppm", "status.json");
215
+ const pidFile = resolve(homedir(), ".ppm", "ppm.pid");
216
+
151
217
  Bun.serve({
152
218
  port,
153
219
  hostname: host,
154
220
  fetch(req, server) {
155
221
  const url = new URL(req.url);
156
222
 
157
- // WebSocket upgrade: /ws/project/:projectName/terminal/:id
158
223
  if (url.pathname.startsWith("/ws/project/")) {
159
224
  const parts = url.pathname.split("/");
160
225
  const projectName = parts[3] ?? "";
@@ -198,4 +263,28 @@ if (process.argv.includes("__serve__")) {
198
263
  },
199
264
  } as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
200
265
  });
266
+
267
+ // Start tunnel if --share was passed (eagerly import so cleanup doesn't race)
268
+ let shareUrl: string | undefined;
269
+ const tunnel = shareFlag
270
+ ? (await import("../services/tunnel.service.ts")).tunnelService
271
+ : null;
272
+ if (tunnel) {
273
+ try {
274
+ shareUrl = await tunnel.startTunnel(port);
275
+ } catch { /* non-fatal: server runs without share URL */ }
276
+ }
277
+
278
+ // Write status file for parent to read
279
+ writeFileSync(statusFile, JSON.stringify({ pid: process.pid, port, host, shareUrl }));
280
+
281
+ // Cleanup on exit
282
+ const cleanup = () => {
283
+ try { unlinkSync(statusFile); } catch {}
284
+ try { unlinkSync(pidFile); } catch {}
285
+ tunnel?.stopTunnel();
286
+ process.exit(0);
287
+ };
288
+ process.on("SIGINT", cleanup);
289
+ process.on("SIGTERM", cleanup);
201
290
  }