@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.
- package/bun.lock +52 -0
- package/dist/web/assets/{chat-tab-CNXjLOhI.js → chat-tab-D7dR7kbZ.js} +1 -1
- package/dist/web/assets/{code-editor-tGMPwYNs.js → code-editor-r8P6Gk4M.js} +1 -1
- package/dist/web/assets/{diff-viewer-B4A8pPbo.js → diff-viewer-vSvrem_i.js} +1 -1
- package/dist/web/assets/{git-graph-ODjrGZOQ.js → git-graph-Cn-s1k0-.js} +1 -1
- package/dist/web/assets/{git-status-panel-B0Im1hrU.js → git-status-panel-QjAQzNAi.js} +1 -1
- package/dist/web/assets/index-DUBI96T5.css +2 -0
- package/dist/web/assets/{index-D2STBl88.js → index-nk1dAWff.js} +2 -2
- package/dist/web/assets/{project-list-VjQQcU3X.js → project-list-DqiatpaH.js} +1 -1
- package/dist/web/assets/{settings-tab-ChhdL0EG.js → settings-tab-iCGeFFdt.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/code-standards.md +74 -0
- package/docs/codebase-summary.md +11 -7
- package/docs/deployment-guide.md +81 -11
- package/docs/project-overview-pdr.md +62 -2
- package/docs/system-architecture.md +24 -8
- package/package.json +3 -1
- package/src/cli/commands/init.ts +186 -43
- package/src/cli/commands/status.ts +73 -0
- package/src/cli/commands/stop.ts +24 -10
- package/src/index.ts +28 -5
- package/src/server/index.ts +104 -15
- package/src/services/cloudflared.service.ts +99 -0
- package/src/services/tunnel.service.ts +100 -0
- package/src/web/app.tsx +2 -2
- package/src/web/components/auth/login-screen.tsx +1 -1
- package/src/web/styles/globals.css +4 -3
- package/dist/web/assets/index-BePIZMuy.css +0 -2
package/src/cli/commands/init.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
-
const ppmDir = resolve(homedir(), ".ppm");
|
|
9
|
-
const globalConfig = resolve(ppmDir, "config.yaml");
|
|
8
|
+
const DEFAULT_PORT = 3210;
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/cli/commands/stop.ts
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
unlinkSync(
|
|
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(
|
|
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
|
|
28
|
-
unlinkSync(
|
|
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("-
|
|
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
|
|
42
|
-
.
|
|
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");
|
package/src/server/index.ts
CHANGED
|
@@ -32,7 +32,9 @@ app.route("/", staticRoutes);
|
|
|
32
32
|
|
|
33
33
|
export async function startServer(options: {
|
|
34
34
|
port?: string;
|
|
35
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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: [
|
|
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
|
-
|
|
63
|
-
|
|
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,
|
|
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.
|
|
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
|
}
|