@hienlh/ppm 0.1.6 → 0.2.1
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/{button-KIZetva8.js → button-CvHWF07y.js} +1 -1
- package/dist/web/assets/{chat-tab-CNXjLOhI.js → chat-tab-C4ovA2w4.js} +3 -3
- package/dist/web/assets/{code-editor-tGMPwYNs.js → code-editor-BgiyQO-M.js} +1 -1
- package/dist/web/assets/{dialog-D8ulRTfX.js → dialog-f3IZM-6v.js} +1 -1
- package/dist/web/assets/{diff-viewer-B4A8pPbo.js → diff-viewer-8_asmBRZ.js} +1 -1
- package/dist/web/assets/{dist-C4W3AGh3.js → dist-CCBctnax.js} +1 -1
- package/dist/web/assets/{git-graph-ODjrGZOQ.js → git-graph-BiyTIbCz.js} +1 -1
- package/dist/web/assets/{git-status-panel-B0Im1hrU.js → git-status-panel-BifyO31N.js} +1 -1
- package/dist/web/assets/index-DILaVO6p.css +2 -0
- package/dist/web/assets/index-DasstYgw.js +11 -0
- package/dist/web/assets/project-list-C7L3hZct.js +1 -0
- package/dist/web/assets/settings-tab-Cn5Ja0_J.js +1 -0
- package/dist/web/assets/{terminal-tab-DDf6S-Tu.js → terminal-tab-CyjhG4Ao.js} +1 -1
- package/dist/web/index.html +8 -8
- 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 +5 -2
- package/src/cli/commands/init.ts +196 -43
- package/src/cli/commands/logs.ts +58 -0
- package/src/cli/commands/report.ts +60 -0
- package/src/cli/commands/status.ts +73 -0
- package/src/cli/commands/stop.ts +24 -10
- package/src/index.ts +48 -6
- package/src/server/index.ts +184 -17
- package/src/services/cloudflared.service.ts +99 -0
- package/src/services/tunnel.service.ts +100 -0
- package/src/types/config.ts +2 -0
- package/src/web/app.tsx +10 -2
- package/src/web/components/auth/login-screen.tsx +9 -2
- package/src/web/components/layout/sidebar.tsx +15 -0
- package/src/web/components/ui/sonner.tsx +9 -6
- package/src/web/hooks/use-health-check.ts +95 -0
- package/src/web/stores/settings-store.ts +19 -0
- package/src/web/stores/tab-store.ts +28 -8
- package/src/web/styles/globals.css +4 -3
- package/dist/web/assets/index-BePIZMuy.css +0 -2
- package/dist/web/assets/index-D2STBl88.js +0 -10
- package/dist/web/assets/project-list-VjQQcU3X.js +0 -1
- package/dist/web/assets/settings-tab-ChhdL0EG.js +0 -1
- /package/dist/web/assets/{dist-PA84y4Ga.js → dist-B6sG2GPc.js} +0 -0
- /package/dist/web/assets/{react-BSLFEYu8.js → react-gOPBns57.js} +0 -0
- /package/dist/web/assets/{utils-DpJF9mAi.js → utils-61GRB9Cb.js} +0 -0
package/src/cli/commands/init.ts
CHANGED
|
@@ -1,57 +1,210 @@
|
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log("\n 🔧 PPM Setup\n");
|
|
43
|
+
|
|
44
|
+
// 1. Device name
|
|
45
|
+
const defaultHostname = (await import("node:os")).hostname();
|
|
46
|
+
const deviceName = nonInteractive
|
|
47
|
+
? (options as any).deviceName ?? defaultHostname
|
|
48
|
+
: await input({
|
|
49
|
+
message: "Device name (shown in UI to identify this machine):",
|
|
50
|
+
default: defaultHostname,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// 2. Port
|
|
54
|
+
const portValue = options.port
|
|
55
|
+
? parseInt(options.port, 10)
|
|
56
|
+
: nonInteractive
|
|
57
|
+
? DEFAULT_PORT
|
|
58
|
+
: parseInt(await input({
|
|
59
|
+
message: "Port:",
|
|
60
|
+
default: String(DEFAULT_PORT),
|
|
61
|
+
validate: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536 ? true : "Enter valid port (1-65535)",
|
|
62
|
+
}), 10);
|
|
63
|
+
|
|
64
|
+
// 2. Scan directory
|
|
65
|
+
const scanDir = options.scan
|
|
66
|
+
?? (nonInteractive
|
|
67
|
+
? homedir()
|
|
68
|
+
: await input({
|
|
69
|
+
message: "Projects directory to scan:",
|
|
70
|
+
default: homedir(),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
// 3. Auth
|
|
74
|
+
const authEnabled = options.auth
|
|
75
|
+
?? (nonInteractive
|
|
76
|
+
? true
|
|
77
|
+
: await confirm({
|
|
78
|
+
message: "Enable authentication?",
|
|
79
|
+
default: true,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
// 4. Password (if auth enabled)
|
|
83
|
+
let authToken = "";
|
|
84
|
+
if (authEnabled) {
|
|
85
|
+
authToken = options.password
|
|
86
|
+
?? (nonInteractive
|
|
87
|
+
? generateToken()
|
|
88
|
+
: await password({
|
|
89
|
+
message: "Set access password (leave empty to auto-generate):",
|
|
90
|
+
}));
|
|
91
|
+
if (!authToken) authToken = generateToken();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 5. Share (install cloudflared)
|
|
95
|
+
const wantShare = options.share
|
|
96
|
+
?? (nonInteractive
|
|
97
|
+
? false
|
|
98
|
+
: await confirm({
|
|
99
|
+
message: "Install cloudflared for public sharing (--share)?",
|
|
100
|
+
default: false,
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
// 6. Advanced settings
|
|
104
|
+
let aiModel = "claude-sonnet-4-6";
|
|
105
|
+
let aiEffort: "low" | "medium" | "high" | "max" = "high";
|
|
106
|
+
let aiMaxTurns = 100;
|
|
107
|
+
let aiApiKeyEnv = "ANTHROPIC_API_KEY";
|
|
108
|
+
|
|
109
|
+
if (!nonInteractive) {
|
|
110
|
+
const wantAdvanced = await confirm({
|
|
111
|
+
message: "Configure advanced AI settings?",
|
|
112
|
+
default: false,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (wantAdvanced) {
|
|
116
|
+
aiModel = await select({
|
|
117
|
+
message: "AI model:",
|
|
118
|
+
choices: [
|
|
119
|
+
{ value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (fast, recommended)" },
|
|
120
|
+
{ value: "claude-opus-4-6", name: "Claude Opus 4.6 (powerful)" },
|
|
121
|
+
{ value: "claude-haiku-4-5", name: "Claude Haiku 4.5 (cheap)" },
|
|
122
|
+
],
|
|
123
|
+
default: "claude-sonnet-4-6",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
aiEffort = await select({
|
|
127
|
+
message: "Thinking effort:",
|
|
128
|
+
choices: [
|
|
129
|
+
{ value: "low" as const, name: "Low" },
|
|
130
|
+
{ value: "medium" as const, name: "Medium" },
|
|
131
|
+
{ value: "high" as const, name: "High (recommended)" },
|
|
132
|
+
{ value: "max" as const, name: "Max" },
|
|
133
|
+
],
|
|
134
|
+
default: "high" as const,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
aiMaxTurns = parseInt(await input({
|
|
138
|
+
message: "Max turns per chat:",
|
|
139
|
+
default: "100",
|
|
140
|
+
validate: (v) => /^\d+$/.test(v) && +v >= 1 && +v <= 500 ? true : "Enter 1-500",
|
|
141
|
+
}), 10);
|
|
142
|
+
|
|
143
|
+
aiApiKeyEnv = await input({
|
|
144
|
+
message: "API key env variable:",
|
|
145
|
+
default: "ANTHROPIC_API_KEY",
|
|
146
|
+
});
|
|
45
147
|
}
|
|
148
|
+
}
|
|
46
149
|
|
|
47
|
-
|
|
150
|
+
// Apply config
|
|
151
|
+
configService.load();
|
|
152
|
+
configService.set("device_name", deviceName);
|
|
153
|
+
configService.set("port", portValue);
|
|
154
|
+
configService.set("auth", { enabled: authEnabled, token: authToken });
|
|
155
|
+
configService.set("ai", {
|
|
156
|
+
default_provider: "claude",
|
|
157
|
+
providers: {
|
|
158
|
+
claude: {
|
|
159
|
+
type: "agent-sdk",
|
|
160
|
+
api_key_env: aiApiKeyEnv,
|
|
161
|
+
model: aiModel,
|
|
162
|
+
effort: aiEffort,
|
|
163
|
+
max_turns: aiMaxTurns,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
configService.save();
|
|
168
|
+
|
|
169
|
+
// Scan for projects
|
|
170
|
+
console.log(`\n Scanning ${scanDir} for git repositories...`);
|
|
171
|
+
const repos = projectService.scanForGitRepos(scanDir);
|
|
172
|
+
const existing = configService.get("projects");
|
|
173
|
+
let added = 0;
|
|
174
|
+
|
|
175
|
+
for (const repoPath of repos) {
|
|
176
|
+
const name = repoPath.split("/").pop() ?? "unknown";
|
|
177
|
+
if (existing.some((p) => resolve(p.path) === repoPath || p.name === name)) continue;
|
|
178
|
+
try {
|
|
179
|
+
projectService.add(repoPath, name);
|
|
180
|
+
added++;
|
|
181
|
+
} catch {}
|
|
48
182
|
}
|
|
183
|
+
console.log(` Found ${repos.length} repo(s), added ${added} new project(s).`);
|
|
49
184
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
185
|
+
// Install cloudflared if requested
|
|
186
|
+
if (wantShare) {
|
|
187
|
+
console.log("\n Installing cloudflared...");
|
|
188
|
+
const { ensureCloudflared } = await import("../../services/cloudflared.service.ts");
|
|
189
|
+
await ensureCloudflared();
|
|
190
|
+
console.log(" ✓ cloudflared ready");
|
|
54
191
|
}
|
|
55
192
|
|
|
56
|
-
|
|
193
|
+
// 8. Next steps
|
|
194
|
+
console.log(`\n ✓ Config saved to ${configService.getConfigPath()}\n`);
|
|
195
|
+
console.log(" Next steps:");
|
|
196
|
+
console.log(` ppm start # Start (daemon, port ${portValue})`);
|
|
197
|
+
console.log(` ppm start -f # Start in foreground`);
|
|
198
|
+
if (wantShare) {
|
|
199
|
+
console.log(` ppm start --share # Start + public URL`);
|
|
200
|
+
}
|
|
201
|
+
if (authEnabled) {
|
|
202
|
+
console.log(`\n Access password: ${authToken}`);
|
|
203
|
+
}
|
|
204
|
+
console.log();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function generateToken(): string {
|
|
208
|
+
const { randomBytes } = require("node:crypto");
|
|
209
|
+
return randomBytes(16).toString("hex");
|
|
57
210
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const LOG_FILE = resolve(homedir(), ".ppm", "ppm.log");
|
|
6
|
+
|
|
7
|
+
export async function showLogs(options: { tail?: string; follow?: boolean; clear?: boolean }) {
|
|
8
|
+
if (options.clear) {
|
|
9
|
+
const { writeFileSync } = await import("node:fs");
|
|
10
|
+
writeFileSync(LOG_FILE, "");
|
|
11
|
+
console.log("Logs cleared.");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!existsSync(LOG_FILE)) {
|
|
16
|
+
console.log("No log file found. Start PPM daemon first.");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const lines = parseInt(options.tail ?? "50", 10);
|
|
21
|
+
const content = readFileSync(LOG_FILE, "utf-8");
|
|
22
|
+
const allLines = content.split("\n");
|
|
23
|
+
const lastN = allLines.slice(-lines).join("\n");
|
|
24
|
+
|
|
25
|
+
if (!lastN.trim()) {
|
|
26
|
+
console.log("Log file is empty.");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log(lastN);
|
|
31
|
+
|
|
32
|
+
if (options.follow) {
|
|
33
|
+
// Tail -f behavior
|
|
34
|
+
const { watch } = await import("node:fs");
|
|
35
|
+
let lastSize = statSync(LOG_FILE).size;
|
|
36
|
+
console.log("\n--- Following logs (Ctrl+C to stop) ---\n");
|
|
37
|
+
|
|
38
|
+
watch(LOG_FILE, () => {
|
|
39
|
+
try {
|
|
40
|
+
const newSize = statSync(LOG_FILE).size;
|
|
41
|
+
if (newSize > lastSize) {
|
|
42
|
+
const fd = Bun.file(LOG_FILE);
|
|
43
|
+
fd.slice(lastSize, newSize).text().then((text) => {
|
|
44
|
+
process.stdout.write(text);
|
|
45
|
+
});
|
|
46
|
+
lastSize = newSize;
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Get last N lines of log for bug reports */
|
|
54
|
+
export function getRecentLogs(lines = 30): string {
|
|
55
|
+
if (!existsSync(LOG_FILE)) return "(no logs)";
|
|
56
|
+
const content = readFileSync(LOG_FILE, "utf-8");
|
|
57
|
+
return content.split("\n").slice(-lines).join("\n").trim() || "(empty)";
|
|
58
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { homedir, platform, arch, release } from "node:os";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { getRecentLogs } from "./logs.ts";
|
|
5
|
+
|
|
6
|
+
const REPO = "hienlh/ppm";
|
|
7
|
+
|
|
8
|
+
export async function reportBug() {
|
|
9
|
+
const version = "0.2.1";
|
|
10
|
+
const logs = getRecentLogs(30);
|
|
11
|
+
const statusFile = resolve(homedir(), ".ppm", "status.json");
|
|
12
|
+
let statusInfo = "(not running)";
|
|
13
|
+
if (existsSync(statusFile)) {
|
|
14
|
+
try { statusInfo = readFileSync(statusFile, "utf-8"); } catch {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const body = [
|
|
18
|
+
"## Environment",
|
|
19
|
+
`- PPM: v${version}`,
|
|
20
|
+
`- OS: ${platform()} ${arch()} ${release()}`,
|
|
21
|
+
`- Bun: ${Bun.version}`,
|
|
22
|
+
"",
|
|
23
|
+
"## Description",
|
|
24
|
+
"<!-- Describe the bug -->",
|
|
25
|
+
"",
|
|
26
|
+
"## Steps to Reproduce",
|
|
27
|
+
"1. ",
|
|
28
|
+
"",
|
|
29
|
+
"## Expected Behavior",
|
|
30
|
+
"",
|
|
31
|
+
"## Daemon Status",
|
|
32
|
+
"```json",
|
|
33
|
+
statusInfo,
|
|
34
|
+
"```",
|
|
35
|
+
"",
|
|
36
|
+
"## Recent Logs (last 30 lines)",
|
|
37
|
+
"```",
|
|
38
|
+
logs,
|
|
39
|
+
"```",
|
|
40
|
+
].join("\n");
|
|
41
|
+
|
|
42
|
+
const title = encodeURIComponent("bug: ");
|
|
43
|
+
const encodedBody = encodeURIComponent(body);
|
|
44
|
+
const url = `https://github.com/${REPO}/issues/new?title=${title}&body=${encodedBody}`;
|
|
45
|
+
|
|
46
|
+
console.log(" Opening GitHub issue form in browser...\n");
|
|
47
|
+
console.log(" Environment info and recent logs will be pre-filled.\n");
|
|
48
|
+
|
|
49
|
+
const { $ } = await import("bun");
|
|
50
|
+
try {
|
|
51
|
+
if (platform() === "darwin") {
|
|
52
|
+
await $`open ${url}`.quiet();
|
|
53
|
+
} else {
|
|
54
|
+
await $`xdg-open ${url}`.quiet();
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
console.log(" Could not open browser. Copy this URL:\n");
|
|
58
|
+
console.log(` ${url}\n`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -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
|
@@ -6,15 +6,22 @@ const program = new Command();
|
|
|
6
6
|
program
|
|
7
7
|
.name("ppm")
|
|
8
8
|
.description("Personal Project Manager — mobile-first web IDE")
|
|
9
|
-
.version("0.1
|
|
9
|
+
.version("0.2.1");
|
|
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")
|
|
@@ -37,11 +53,37 @@ program
|
|
|
37
53
|
});
|
|
38
54
|
|
|
39
55
|
program
|
|
40
|
-
.command("
|
|
41
|
-
.description("
|
|
56
|
+
.command("logs")
|
|
57
|
+
.description("View PPM daemon logs")
|
|
58
|
+
.option("-n, --tail <lines>", "Number of lines to show", "50")
|
|
59
|
+
.option("-f, --follow", "Follow log output")
|
|
60
|
+
.option("--clear", "Clear log file")
|
|
61
|
+
.action(async (options) => {
|
|
62
|
+
const { showLogs } = await import("./cli/commands/logs.ts");
|
|
63
|
+
await showLogs(options);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
program
|
|
67
|
+
.command("report")
|
|
68
|
+
.description("Report a bug on GitHub (pre-fills env info + logs)")
|
|
42
69
|
.action(async () => {
|
|
70
|
+
const { reportBug } = await import("./cli/commands/report.ts");
|
|
71
|
+
await reportBug();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
program
|
|
75
|
+
.command("init")
|
|
76
|
+
.description("Initialize PPM configuration (interactive or via flags)")
|
|
77
|
+
.option("-p, --port <port>", "Port to listen on")
|
|
78
|
+
.option("--scan <path>", "Directory to scan for git repos")
|
|
79
|
+
.option("--auth", "Enable authentication")
|
|
80
|
+
.option("--no-auth", "Disable authentication")
|
|
81
|
+
.option("--password <pw>", "Set access password")
|
|
82
|
+
.option("--share", "Pre-install cloudflared for sharing")
|
|
83
|
+
.option("-y, --yes", "Non-interactive mode (use defaults + flags)")
|
|
84
|
+
.action(async (options) => {
|
|
43
85
|
const { initProject } = await import("./cli/commands/init.ts");
|
|
44
|
-
await initProject();
|
|
86
|
+
await initProject(options);
|
|
45
87
|
});
|
|
46
88
|
|
|
47
89
|
const { registerProjectsCommands } = await import("./cli/commands/projects.ts");
|