@hienlh/ppm 0.2.1 → 0.2.2

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/CHANGELOG.md ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ ## [0.2.2] - 2026-03-15
4
+
5
+ ### Added
6
+ - Single source of truth for version (`src/version.ts` reads from `package.json`)
7
+ - Version shown in daemon start output
8
+
9
+ ### Fixed
10
+ - Version no longer hardcoded in multiple files
11
+
12
+ ## [0.2.1] - 2026-03-15
13
+
14
+ ### Added
15
+ - `ppm logs` command — view daemon logs (`-n`, `-f`, `--clear`)
16
+ - `ppm report` command — open GitHub issue pre-filled with env info + logs
17
+ - Daemon stdout/stderr written to `~/.ppm/ppm.log`
18
+ - Frontend health check — detects server crash, prompts bug report
19
+ - Sensitive data (tokens, passwords, API keys) auto-redacted from logs
20
+ - `/api/logs/recent` public endpoint for bug reports
21
+
22
+ ## [0.2.0] - 2026-03-15
23
+
24
+ ### Added
25
+ - `--share` / `-s` flag — public URL via Cloudflare Quick Tunnel
26
+ - Default daemon mode — `ppm start` runs in background
27
+ - `--foreground` / `-f` flag — opt-in foreground mode
28
+ - `ppm status` command with `--json` flag and QR code
29
+ - `ppm init` — interactive setup (port, auth, password, share, AI settings)
30
+ - Auto-init on first `ppm start`
31
+ - Non-interactive init via flags (`-y`, `--port`, `--auth`, `--password`)
32
+ - `device_name` config — shown in sidebar, login screen, page title
33
+ - `/api/info` public endpoint (version + device name)
34
+ - QR code for share URL in terminal
35
+ - Auth warning when sharing with auth disabled
36
+ - Cloudflared auto-download (macOS .tgz + Linux binary)
37
+ - Tunnel runs as independent process — survives server crash
38
+ - `ppm start --share` reuses existing tunnel if alive
39
+
40
+ ### Changed
41
+ - `ppm start` defaults to daemon mode (was opt-in)
42
+ - Status file `~/.ppm/status.json` replaces `ppm.pid` (with fallback)
43
+ - `ppm stop` kills both server and tunnel, cleans up files
44
+
45
+ ## [0.1.6] - 2026-03-15
46
+
47
+ ### Added
48
+ - Configurable AI provider settings via `ppm.yaml`, API, and UI
49
+ - Chat tool UI improvements, diff viewer, git panels, editor enhancements
50
+
51
+ ### Fixed
52
+ - Global focus-visible outline causing blue ring on all inputs
53
+ - Unified input styles across app
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -6,7 +6,7 @@ import { getRecentLogs } from "./logs.ts";
6
6
  const REPO = "hienlh/ppm";
7
7
 
8
8
  export async function reportBug() {
9
- const version = "0.2.1";
9
+ const { VERSION: version } = await import("../../version.ts");
10
10
  const logs = getRecentLogs(30);
11
11
  const statusFile = resolve(homedir(), ".ppm", "status.json");
12
12
  let statusInfo = "(not running)";
@@ -11,40 +11,46 @@ interface DaemonStatus {
11
11
  port: number | null;
12
12
  host: string | null;
13
13
  shareUrl: string | null;
14
+ tunnelPid: number | null;
15
+ tunnelAlive: boolean;
16
+ }
17
+
18
+ function isAlive(pid: number): boolean {
19
+ try { process.kill(pid, 0); return true; } catch { return false; }
14
20
  }
15
21
 
16
22
  function getDaemonStatus(): DaemonStatus {
17
- const notRunning: DaemonStatus = { running: false, pid: null, port: null, host: null, shareUrl: null };
23
+ const dead: DaemonStatus = {
24
+ running: false, pid: null, port: null, host: null,
25
+ shareUrl: null, tunnelPid: null, tunnelAlive: false,
26
+ };
18
27
 
19
- // Try status.json first
20
28
  if (existsSync(STATUS_FILE)) {
21
29
  try {
22
30
  const data = JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
23
31
  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
- }
32
+ const tunnelPid = (data.tunnelPid as number) ?? null;
33
+ const tunnelAlive = tunnelPid ? isAlive(tunnelPid) : false;
34
+ return {
35
+ running: isAlive(pid),
36
+ pid,
37
+ port: data.port,
38
+ host: data.host,
39
+ shareUrl: data.shareUrl ?? null,
40
+ tunnelPid,
41
+ tunnelAlive,
42
+ };
43
+ } catch { return dead; }
34
44
  }
35
45
 
36
- // Fallback to ppm.pid
37
46
  if (existsSync(PID_FILE)) {
38
47
  try {
39
48
  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
- }
49
+ return { ...dead, running: isAlive(pid), pid };
50
+ } catch { return dead; }
45
51
  }
46
52
 
47
- return notRunning;
53
+ return dead;
48
54
  }
49
55
 
50
56
  export async function showStatus(options: { json?: boolean }) {
@@ -55,14 +61,17 @@ export async function showStatus(options: { json?: boolean }) {
55
61
  return;
56
62
  }
57
63
 
58
- if (!status.running) {
64
+ if (!status.running && !status.tunnelAlive) {
59
65
  console.log(" PPM is not running.");
60
66
  return;
61
67
  }
62
68
 
63
- console.log(`\n PPM daemon is running\n`);
64
- console.log(` PID: ${status.pid}`);
69
+ console.log(`\n PPM daemon status\n`);
70
+ console.log(` Server: ${status.running ? "running" : "stopped"} (PID: ${status.pid})`);
65
71
  if (status.port) console.log(` Local: http://localhost:${status.port}/`);
72
+ if (status.tunnelPid) {
73
+ console.log(` Tunnel: ${status.tunnelAlive ? "running" : "stopped"} (PID: ${status.tunnelPid})`);
74
+ }
66
75
  if (status.shareUrl) {
67
76
  console.log(` Share: ${status.shareUrl}`);
68
77
  const qr = await import("qrcode-terminal");
@@ -5,43 +5,51 @@ import { readFileSync, unlinkSync, existsSync } from "node:fs";
5
5
  const PID_FILE = resolve(homedir(), ".ppm", "ppm.pid");
6
6
  const STATUS_FILE = resolve(homedir(), ".ppm", "status.json");
7
7
 
8
+ function killPid(pid: number, label: string): boolean {
9
+ try {
10
+ process.kill(pid);
11
+ console.log(` Stopped ${label} (PID: ${pid})`);
12
+ return true;
13
+ } catch (e) {
14
+ const err = e as NodeJS.ErrnoException;
15
+ if (err.code !== "ESRCH") console.error(` Failed to stop ${label}: ${err.message}`);
16
+ return false;
17
+ }
18
+ }
19
+
8
20
  export async function stopServer() {
9
- let pid: number | null = null;
21
+ let status: { pid?: number; tunnelPid?: number } | null = null;
10
22
 
11
- // Try status.json first (new format)
23
+ // Read status.json
12
24
  if (existsSync(STATUS_FILE)) {
13
- try {
14
- const status = JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
15
- pid = status.pid;
16
- } catch {}
25
+ try { status = JSON.parse(readFileSync(STATUS_FILE, "utf-8")); } catch {}
17
26
  }
18
27
 
19
- // Fallback to ppm.pid (compat)
20
- if (!pid && existsSync(PID_FILE)) {
21
- pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
22
- }
28
+ // Fallback to ppm.pid
29
+ const pidFromFile = existsSync(PID_FILE)
30
+ ? parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10)
31
+ : NaN;
32
+
33
+ const serverPid = status?.pid ?? (isNaN(pidFromFile) ? null : pidFromFile);
34
+ const tunnelPid = status?.tunnelPid ?? null;
23
35
 
24
- if (!pid || isNaN(pid)) {
36
+ if (!serverPid && !tunnelPid) {
25
37
  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);
38
+ cleanup();
29
39
  return;
30
40
  }
31
41
 
32
- try {
33
- process.kill(pid);
34
- if (existsSync(STATUS_FILE)) unlinkSync(STATUS_FILE);
35
- if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
36
- console.log(`PPM daemon stopped (PID: ${pid}).`);
37
- } catch (e) {
38
- const error = e as NodeJS.ErrnoException;
39
- if (error.code === "ESRCH") {
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);
43
- } else {
44
- console.error(`Failed to stop process ${pid}: ${error.message}`);
45
- }
46
- }
42
+ // Kill server process
43
+ if (serverPid) killPid(serverPid, "server");
44
+
45
+ // Kill tunnel process (independent from server)
46
+ if (tunnelPid) killPid(tunnelPid, "tunnel");
47
+
48
+ cleanup();
49
+ console.log("PPM stopped.");
50
+ }
51
+
52
+ function cleanup() {
53
+ if (existsSync(STATUS_FILE)) unlinkSync(STATUS_FILE);
54
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
47
55
  }
package/src/index.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Command } from "commander";
3
+ import { VERSION } from "./version.ts";
3
4
 
4
5
  const program = new Command();
5
6
 
6
7
  program
7
8
  .name("ppm")
8
9
  .description("Personal Project Manager — mobile-first web IDE")
9
- .version("0.2.1");
10
+ .version(VERSION);
10
11
 
11
12
  program
12
13
  .command("start")
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { cors } from "hono/cors";
3
3
  import { configService } from "../services/config.service.ts";
4
+ import { VERSION } from "../version.ts";
4
5
  import { authMiddleware } from "./middleware/auth.ts";
5
6
  import { projectRoutes } from "./routes/projects.ts";
6
7
  import { settingsRoutes } from "./routes/settings.ts";
@@ -61,7 +62,7 @@ app.use("*", cors());
61
62
  // Public endpoints (before auth)
62
63
  app.get("/api/health", (c) => c.json(ok({ status: "running" })));
63
64
  app.get("/api/info", (c) => c.json(ok({
64
- version: "0.2.1",
65
+ version: VERSION,
65
66
  device_name: configService.get("device_name") || null,
66
67
  })));
67
68
 
@@ -129,55 +130,87 @@ export async function startServer(options: {
129
130
  const pidFile = resolve(ppmDir, "ppm.pid");
130
131
  const statusFile = resolve(ppmDir, "status.json");
131
132
 
132
- // If --share, download cloudflared in parent (shows progress to user)
133
+ // If --share, download cloudflared and start tunnel as independent process
134
+ let shareUrl: string | undefined;
135
+ let tunnelPid: number | undefined;
133
136
  if (options.share) {
134
137
  const { ensureCloudflared } = await import("../services/cloudflared.service.ts");
135
- await ensureCloudflared();
138
+ const bin = await ensureCloudflared();
139
+
140
+ // Check if tunnel already running (reuse from previous server crash)
141
+ if (existsSync(statusFile)) {
142
+ try {
143
+ const prev = JSON.parse(readFileSync(statusFile, "utf-8"));
144
+ if (prev.tunnelPid && prev.shareUrl) {
145
+ try {
146
+ process.kill(prev.tunnelPid, 0); // Check alive
147
+ console.log(` Reusing existing tunnel (PID: ${prev.tunnelPid})`);
148
+ shareUrl = prev.shareUrl;
149
+ tunnelPid = prev.tunnelPid;
150
+ } catch { /* tunnel dead, spawn new one */ }
151
+ }
152
+ } catch {}
153
+ }
154
+
155
+ // Spawn new tunnel if no existing one
156
+ if (!shareUrl) {
157
+ console.log(" Starting share tunnel...");
158
+ const { openSync: openFd } = await import("node:fs");
159
+ const tunnelLog = resolve(ppmDir, "tunnel.log");
160
+ const tfd = openFd(tunnelLog, "a");
161
+ const tunnelProc = Bun.spawn({
162
+ cmd: [bin, "tunnel", "--url", `http://localhost:${port}`],
163
+ stdio: ["ignore", "ignore", tfd],
164
+ env: process.env,
165
+ });
166
+ tunnelProc.unref();
167
+ tunnelPid = tunnelProc.pid;
168
+
169
+ // Parse URL from tunnel.log (poll stderr output)
170
+ const urlRegex = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
171
+ const pollStart = Date.now();
172
+ while (Date.now() - pollStart < 30_000) {
173
+ await Bun.sleep(500);
174
+ try {
175
+ const logContent = readFileSync(tunnelLog, "utf-8");
176
+ const match = logContent.match(urlRegex);
177
+ if (match) { shareUrl = match[0]; break; }
178
+ } catch {}
179
+ }
180
+ if (!shareUrl) console.warn(" ⚠ Tunnel started but URL not detected.");
181
+ }
136
182
  }
137
183
 
138
- // Spawn child process with log file
184
+ // Spawn server child process with log file
139
185
  const { openSync } = await import("node:fs");
140
186
  const logFile = resolve(ppmDir, "ppm.log");
141
187
  const logFd = openSync(logFile, "a");
142
188
  const child = Bun.spawn({
143
189
  cmd: [
144
190
  process.execPath, "run", import.meta.dir + "/index.ts", "__serve__",
145
- String(port), host, options.config ?? "", options.share ? "share" : "",
191
+ String(port), host, options.config ?? "",
146
192
  ],
147
193
  stdio: ["ignore", logFd, logFd],
148
194
  env: process.env,
149
195
  });
150
196
  child.unref();
151
- writeFileSync(pidFile, String(child.pid));
152
197
 
153
- // Poll for status.json (child writes it when ready)
154
- const startTime = Date.now();
155
- let status: { pid: number; port: number; host: string; shareUrl?: string } | null = null;
156
- while (Date.now() - startTime < 30_000) {
157
- if (existsSync(statusFile)) {
158
- try {
159
- status = JSON.parse(readFileSync(statusFile, "utf-8"));
160
- break;
161
- } catch { /* file not fully written yet */ }
162
- }
163
- await Bun.sleep(200);
164
- }
198
+ // Write status file with both PIDs
199
+ const status = { pid: child.pid, port, host, shareUrl, tunnelPid };
200
+ writeFileSync(statusFile, JSON.stringify(status));
201
+ writeFileSync(pidFile, String(child.pid));
165
202
 
166
- if (status) {
167
- console.log(`\n PPM daemon started (PID: ${status.pid})\n`);
168
- console.log(` ➜ Local: http://localhost:${status.port}/`);
169
- if (status.shareUrl) {
170
- console.log(` ➜ Share: ${status.shareUrl}`);
171
- if (!configService.get("auth").enabled) {
172
- console.log(`\n ⚠ Warning: auth is disabled your IDE is publicly accessible!`);
173
- console.log(` Enable auth in ~/.ppm/config.yaml or restart without --share.`);
174
- }
175
- const qr = await import("qrcode-terminal");
176
- console.log();
177
- qr.generate(status.shareUrl, { small: true });
203
+ console.log(`\n PPM v${VERSION} daemon started (PID: ${child.pid})\n`);
204
+ console.log(` ➜ Local: http://localhost:${port}/`);
205
+ if (shareUrl) {
206
+ console.log(` ➜ Share: ${shareUrl}`);
207
+ if (!configService.get("auth").enabled) {
208
+ console.log(`\n ⚠ Warning: auth is disabled — your IDE is publicly accessible!`);
209
+ console.log(` Enable auth in ~/.ppm/config.yaml or restart without --share.`);
178
210
  }
179
- } else {
180
- console.log(`\n PPM daemon started (PID: ${child.pid}) but status not confirmed.`);
211
+ const qr = await import("qrcode-terminal");
212
+ console.log();
213
+ qr.generate(shareUrl, { small: true });
181
214
  }
182
215
 
183
216
  process.exit(0);
@@ -235,7 +268,7 @@ export async function startServer(options: {
235
268
  } as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
236
269
  });
237
270
 
238
- console.log(`\n PPM v0.2.1 ready\n`);
271
+ console.log(`\n PPM v${VERSION} ready\n`);
239
272
  console.log(` ➜ Local: http://localhost:${server.port}/`);
240
273
 
241
274
  const { networkInterfaces } = await import("node:os");
@@ -281,16 +314,9 @@ if (process.argv.includes("__serve__")) {
281
314
  const port = parseInt(process.argv[idx + 1] ?? "8080", 10);
282
315
  const host = process.argv[idx + 2] ?? "0.0.0.0";
283
316
  const configPath = process.argv[idx + 3] || undefined;
284
- const shareFlag = process.argv[idx + 4] === "share";
285
317
 
286
318
  configService.load(configPath);
287
-
288
- const { resolve } = await import("node:path");
289
- const { homedir } = await import("node:os");
290
- const { writeFileSync, unlinkSync } = await import("node:fs");
291
-
292
- const statusFile = resolve(homedir(), ".ppm", "status.json");
293
- const pidFile = resolve(homedir(), ".ppm", "ppm.pid");
319
+ await setupLogFile();
294
320
 
295
321
  Bun.serve({
296
322
  port,
@@ -342,27 +368,5 @@ if (process.argv.includes("__serve__")) {
342
368
  } as Parameters<typeof Bun.serve>[0] extends { websocket?: infer W } ? W : never,
343
369
  });
344
370
 
345
- // Start tunnel if --share was passed (eagerly import so cleanup doesn't race)
346
- let shareUrl: string | undefined;
347
- const tunnel = shareFlag
348
- ? (await import("../services/tunnel.service.ts")).tunnelService
349
- : null;
350
- if (tunnel) {
351
- try {
352
- shareUrl = await tunnel.startTunnel(port);
353
- } catch { /* non-fatal: server runs without share URL */ }
354
- }
355
-
356
- // Write status file for parent to read
357
- writeFileSync(statusFile, JSON.stringify({ pid: process.pid, port, host, shareUrl }));
358
-
359
- // Cleanup on exit
360
- const cleanup = () => {
361
- try { unlinkSync(statusFile); } catch {}
362
- try { unlinkSync(pidFile); } catch {}
363
- tunnel?.stopTunnel();
364
- process.exit(0);
365
- };
366
- process.on("SIGINT", cleanup);
367
- process.on("SIGTERM", cleanup);
371
+ console.log(`Server child ready on port ${port}`);
368
372
  }
package/src/version.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ const pkg = JSON.parse(readFileSync(resolve(import.meta.dir, "../package.json"), "utf-8"));
5
+
6
+ /** App version from package.json — single source of truth */
7
+ export const VERSION: string = pkg.version;