@hienlh/ppm 0.8.52 → 0.8.54

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 (40) hide show
  1. package/.claude.bak/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/CHANGELOG.md +39 -0
  4. package/dist/web/assets/{chat-tab-wunayDmr.js → chat-tab-CgVh-OsO.js} +1 -1
  5. package/dist/web/assets/{code-editor-Fw_VrmHT.js → code-editor-DgvZlpB7.js} +1 -1
  6. package/dist/web/assets/{database-viewer-CZjxdELm.js → database-viewer-CRZksTo-.js} +1 -1
  7. package/dist/web/assets/{diff-viewer-B51YfMeK.js → diff-viewer-CPNLuddT.js} +1 -1
  8. package/dist/web/assets/{git-graph-fCVmtbaj.js → git-graph-BCtMSQwB.js} +1 -1
  9. package/dist/web/assets/index-CfSJP_Fv.css +2 -0
  10. package/dist/web/assets/index-DcJqqWbL.js +37 -0
  11. package/dist/web/assets/keybindings-store-C1HiSDRb.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-D_OeJdOH.js → markdown-renderer-Ci7qz558.js} +1 -1
  13. package/dist/web/assets/{postgres-viewer-BlEIES7N.js → postgres-viewer-C8PRJ87B.js} +1 -1
  14. package/dist/web/assets/{settings-tab-DnU5t6Fy.js → settings-tab-CqnP28Dq.js} +1 -1
  15. package/dist/web/assets/{sqlite-viewer-BJ2s8Dng.js → sqlite-viewer-BSceyudC.js} +1 -1
  16. package/dist/web/assets/{terminal-tab-DAFbT7Sv.js → terminal-tab-0Y48dynP.js} +1 -1
  17. package/dist/web/index.html +2 -2
  18. package/dist/web/sw.js +1 -1
  19. package/docs/project-changelog.md +78 -0
  20. package/docs/project-roadmap.md +1 -0
  21. package/docs/system-architecture.md +13 -1
  22. package/package.json +1 -1
  23. package/src/cli/commands/restart.ts +39 -0
  24. package/src/cli/commands/status.ts +10 -0
  25. package/src/cli/commands/stop.ts +34 -16
  26. package/src/cli/commands/upgrade.ts +49 -0
  27. package/src/index.ts +9 -0
  28. package/src/server/index.ts +100 -112
  29. package/src/server/routes/upgrade.ts +57 -0
  30. package/src/services/autostart-generator.ts +2 -5
  31. package/src/services/db.service.ts +1 -1
  32. package/src/services/supervisor.ts +469 -0
  33. package/src/services/upgrade.service.ts +115 -0
  34. package/src/web/app.tsx +4 -0
  35. package/src/web/components/layout/upgrade-banner.tsx +102 -0
  36. package/dist/web/assets/index-CoyMn-Mj.css +0 -2
  37. package/dist/web/assets/index-DMlEKjZt.js +0 -37
  38. package/dist/web/assets/keybindings-store-BzXZa5uC.js +0 -1
  39. package/snapshot-state.md +0 -1526
  40. package/test-tokens.mjs +0 -212
@@ -2,8 +2,9 @@ import { resolve } from "node:path";
2
2
  import { homedir } from "node:os";
3
3
  import { readFileSync, unlinkSync, existsSync } from "node:fs";
4
4
 
5
- const PID_FILE = resolve(homedir(), ".ppm", "ppm.pid");
6
- const STATUS_FILE = resolve(homedir(), ".ppm", "status.json");
5
+ const PPM_DIR = process.env.PPM_HOME || resolve(homedir(), ".ppm");
6
+ const PID_FILE = resolve(PPM_DIR, "ppm.pid");
7
+ const STATUS_FILE = resolve(PPM_DIR, "status.json");
7
8
 
8
9
  function killPid(pid: number, label: string): boolean {
9
10
  try {
@@ -54,53 +55,70 @@ export async function stopServer(options?: { all?: boolean }) {
54
55
  if (options?.all) {
55
56
  console.log(" Stopping all PPM and cloudflared processes...\n");
56
57
  const cfKilled = killAllByName("cloudflared");
57
- // Kill bun processes listening on PPM ports (from status.json or common ports)
58
- let serverKilled = 0;
58
+ let killed = 0;
59
59
  if (existsSync(STATUS_FILE)) {
60
60
  try {
61
61
  const data = JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
62
- if (data.pid) { killPid(data.pid, "server"); serverKilled++; }
62
+ // Kill supervisor first (cascades to server + tunnel children)
63
+ if (data.supervisorPid) { killPid(data.supervisorPid, "supervisor"); killed++; }
64
+ if (data.pid) { killPid(data.pid, "server"); killed++; }
65
+ if (data.tunnelPid) { killPid(data.tunnelPid, "tunnel"); killed++; }
63
66
  } catch {}
64
67
  }
65
68
  if (existsSync(PID_FILE)) {
66
69
  try {
67
70
  const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10);
68
- if (!isNaN(pid)) { killPid(pid, "server (pidfile)"); serverKilled++; }
71
+ if (!isNaN(pid)) { killPid(pid, "supervisor/server (pidfile)"); killed++; }
69
72
  } catch {}
70
73
  }
71
74
  cleanup();
72
- console.log(`\n Done. Killed ${cfKilled} cloudflared + ${serverKilled} server process(es).`);
75
+ console.log(`\n Done. Killed ${cfKilled} cloudflared + ${killed} PPM process(es).`);
73
76
  return;
74
77
  }
75
78
 
76
- let status: { pid?: number; tunnelPid?: number } | null = null;
79
+ let status: { pid?: number; tunnelPid?: number; supervisorPid?: number } | null = null;
77
80
 
78
81
  // Read status.json
79
82
  if (existsSync(STATUS_FILE)) {
80
83
  try { status = JSON.parse(readFileSync(STATUS_FILE, "utf-8")); } catch {}
81
84
  }
82
85
 
83
- // Fallback to ppm.pid
86
+ // Fallback to ppm.pid (now stores supervisor PID)
84
87
  const pidFromFile = existsSync(PID_FILE)
85
88
  ? parseInt(readFileSync(PID_FILE, "utf-8").trim(), 10)
86
89
  : NaN;
87
90
 
88
- const serverPid = status?.pid ?? (isNaN(pidFromFile) ? null : pidFromFile);
91
+ const supervisorPid = status?.supervisorPid ?? null;
92
+ const serverPid = status?.pid ?? null;
89
93
  const tunnelPid = status?.tunnelPid ?? null;
94
+ const fallbackPid = isNaN(pidFromFile) ? null : pidFromFile;
90
95
 
91
- if (!serverPid && !tunnelPid) {
96
+ if (!supervisorPid && !serverPid && !tunnelPid && !fallbackPid) {
92
97
  console.log("No PPM daemon running.");
93
98
  cleanup();
94
99
  return;
95
100
  }
96
101
 
97
- // Kill server process
98
- if (serverPid) killPid(serverPid, "server");
102
+ // Kill supervisor first — its SIGTERM handler kills server + tunnel children
103
+ if (supervisorPid) {
104
+ killPid(supervisorPid, "supervisor");
105
+ // Give supervisor 2s to gracefully kill children
106
+ await Bun.sleep(2000);
107
+ } else if (fallbackPid) {
108
+ // Legacy: ppm.pid might be server PID (pre-supervisor) or supervisor PID
109
+ killPid(fallbackPid, "supervisor/server (pidfile)");
110
+ await Bun.sleep(1000);
111
+ }
99
112
 
100
- // Kill tunnel process (independent from server)
101
- if (tunnelPid) killPid(tunnelPid, "tunnel");
113
+ // Kill remaining children if supervisor didn't clean them up
114
+ if (serverPid) {
115
+ try { process.kill(serverPid, 0); killPid(serverPid, "server"); } catch {}
116
+ }
117
+ if (tunnelPid) {
118
+ try { process.kill(tunnelPid, 0); killPid(tunnelPid, "tunnel"); } catch {}
119
+ }
102
120
 
103
- // Windows fallback: kill orphan cloudflared processes spawned by PPM
121
+ // Windows fallback: kill orphan cloudflared processes
104
122
  if (process.platform === "win32") {
105
123
  try {
106
124
  Bun.spawnSync(["taskkill", "/F", "/IM", "cloudflared.exe"], { stdout: "ignore", stderr: "ignore" });
@@ -0,0 +1,49 @@
1
+ import { VERSION } from "../../version.ts";
2
+ import {
3
+ checkForUpdate,
4
+ applyUpgrade,
5
+ getInstallMethod,
6
+ signalSupervisorUpgrade,
7
+ } from "../../services/upgrade.service.ts";
8
+
9
+ export async function upgradeCmd(options: { check?: boolean }) {
10
+ const method = getInstallMethod();
11
+
12
+ if (method === "binary") {
13
+ console.log(" Compiled binary detected — download new version from GitHub releases.");
14
+ process.exit(0);
15
+ }
16
+
17
+ const update = await checkForUpdate();
18
+
19
+ if (options.check) {
20
+ if (update.available) {
21
+ console.log(` Update available: v${update.current} → v${update.latest}`);
22
+ } else {
23
+ console.log(` Already on latest version (v${VERSION})`);
24
+ }
25
+ process.exit(0);
26
+ }
27
+
28
+ if (!update.available) {
29
+ console.log(` Already on latest version (v${VERSION})`);
30
+ process.exit(0);
31
+ }
32
+
33
+ console.log(` Upgrading from v${update.current} to v${update.latest}...`);
34
+ const result = await applyUpgrade();
35
+
36
+ if (!result.success) {
37
+ console.error(` ✗ Upgrade failed: ${result.error}`);
38
+ process.exit(1);
39
+ }
40
+
41
+ console.log(` ✓ Upgraded to v${result.newVersion}`);
42
+
43
+ const signal = signalSupervisorUpgrade();
44
+ if (signal.sent) {
45
+ console.log(" Restarting PPM...");
46
+ } else {
47
+ console.log(" Restart PPM manually with `ppm restart`");
48
+ }
49
+ }
package/src/index.ts CHANGED
@@ -121,6 +121,15 @@ registerGitCommands(program);
121
121
  const { registerChatCommands } = await import("./cli/commands/chat-cmd.ts");
122
122
  registerChatCommands(program);
123
123
 
124
+ program
125
+ .command("upgrade")
126
+ .description("Check for and install PPM updates")
127
+ .option("--check", "Only check for updates, don't install")
128
+ .action(async (options) => {
129
+ const { upgradeCmd } = await import("./cli/commands/upgrade.ts");
130
+ await upgradeCmd(options);
131
+ });
132
+
124
133
  const { registerAutoStartCommands } = await import("./cli/commands/autostart.ts");
125
134
  registerAutoStartCommands(program);
126
135
 
@@ -25,7 +25,7 @@ async function setupLogFile() {
25
25
  const { homedir } = await import("node:os");
26
26
  const { appendFileSync, mkdirSync, existsSync } = await import("node:fs");
27
27
 
28
- const ppmDir = resolve(homedir(), ".ppm");
28
+ const ppmDir = process.env.PPM_HOME || resolve(homedir(), ".ppm");
29
29
  if (!existsSync(ppmDir)) mkdirSync(ppmDir, { recursive: true });
30
30
  const logPath = resolve(ppmDir, "ppm.log");
31
31
 
@@ -53,12 +53,29 @@ async function setupLogFile() {
53
53
  console.error = (...args: unknown[]) => { origError(...args); writeLog("ERROR", args); };
54
54
  console.warn = (...args: unknown[]) => { origWarn(...args); writeLog("WARN", args); };
55
55
 
56
- // Capture uncaught errors
56
+ // Capture uncaught errors — count-based exit for supervisor restart
57
+ let exceptionCount = 0;
58
+ let lastExceptionTime = 0;
59
+
60
+ const handleFatalError = (label: string, detail: string) => {
61
+ writeLog("FATAL", [`${label}: ${detail}`]);
62
+ const now = Date.now();
63
+ if (now - lastExceptionTime < 60_000) exceptionCount++;
64
+ else exceptionCount = 1;
65
+ lastExceptionTime = now;
66
+
67
+ // 3+ fatal errors in 1 minute → exit and let supervisor restart fresh
68
+ if (exceptionCount >= 3) {
69
+ writeLog("FATAL", ["Too many errors in 1 min, exiting for supervisor restart"]);
70
+ process.exit(1);
71
+ }
72
+ };
73
+
57
74
  process.on("uncaughtException", (err) => {
58
- writeLog("FATAL", [`Uncaught exception: ${err.stack ?? err.message}`]);
75
+ handleFatalError("Uncaught exception", err.stack ?? err.message);
59
76
  });
60
77
  process.on("unhandledRejection", (reason) => {
61
- writeLog("FATAL", [`Unhandled rejection: ${reason}`]);
78
+ handleFatalError("Unhandled rejection", String(reason));
62
79
  });
63
80
  }
64
81
 
@@ -122,6 +139,10 @@ app.route("/api/postgres", postgresRoutes);
122
139
  app.route("/api/db", databaseRoutes);
123
140
  app.route("/api/accounts", accountsRoutes);
124
141
 
142
+ // Upgrade routes (check for updates, apply upgrade)
143
+ import { upgradeRoutes } from "./routes/upgrade.ts";
144
+ app.route("/api/upgrade", upgradeRoutes);
145
+
125
146
  // Cloud device registry
126
147
  import { cloudRoutes } from "./routes/cloud.ts";
127
148
  app.route("/api/cloud", cloudRoutes);
@@ -168,110 +189,52 @@ export async function startServer(options: {
168
189
  if (isDaemon) {
169
190
  const { resolve } = await import("node:path");
170
191
  const { homedir } = await import("node:os");
171
- const { writeFileSync, readFileSync, mkdirSync, existsSync } = await import("node:fs");
192
+ const { writeFileSync, readFileSync, mkdirSync, existsSync, openSync } = await import("node:fs");
193
+ const { isCompiledBinary } = await import("../services/autostart-generator.ts");
172
194
 
173
- const ppmDir = resolve(homedir(), ".ppm");
195
+ const ppmDir = process.env.PPM_HOME || resolve(homedir(), ".ppm");
174
196
  if (!existsSync(ppmDir)) mkdirSync(ppmDir, { recursive: true });
175
197
  const pidFile = resolve(ppmDir, "ppm.pid");
176
198
  const statusFile = resolve(ppmDir, "status.json");
177
199
 
178
- // If --share, download cloudflared and start tunnel as independent process
179
- let shareUrl: string | undefined;
180
- let tunnelPid: number | undefined;
200
+ // Kill any leftover processes from previous run
201
+ if (existsSync(statusFile)) {
202
+ try {
203
+ const prev = JSON.parse(readFileSync(statusFile, "utf-8"));
204
+ if (prev.supervisorPid) { try { process.kill(prev.supervisorPid); } catch {} }
205
+ else if (prev.pid) { try { process.kill(prev.pid); } catch {} }
206
+ if (prev.tunnelPid) { try { process.kill(prev.tunnelPid); } catch {} }
207
+ } catch {}
208
+ }
209
+
210
+ // Pre-download cloudflared if --share (so supervisor doesn't need to)
181
211
  if (options.share) {
212
+ console.log(" Ensuring cloudflared is available...");
182
213
  const { ensureCloudflared } = await import("../services/cloudflared.service.ts");
183
- const bin = await ensureCloudflared();
184
-
185
- // Kill any leftover tunnel from previous run
186
- if (existsSync(statusFile)) {
187
- try {
188
- const prev = JSON.parse(readFileSync(statusFile, "utf-8"));
189
- if (prev.tunnelPid) {
190
- try { process.kill(prev.tunnelPid); } catch { /* already dead */ }
191
- }
192
- } catch {}
193
- }
194
-
195
- // Spawn new tunnel if no existing one
196
- if (!shareUrl) {
197
- console.log(" Starting share tunnel...");
198
- const { openSync: openFd, writeFileSync: writeFs } = await import("node:fs");
199
- const tunnelLog = resolve(ppmDir, "tunnel.log");
200
- // Truncate old log so we only match the new tunnel URL
201
- writeFs(tunnelLog, "");
202
-
203
- if (process.platform === "win32") {
204
- // Windows: use PowerShell for detached tunnel process
205
- const psCmd = [
206
- `$p = Start-Process -PassThru -WindowStyle Hidden`,
207
- `-FilePath '${bin.replace(/\\/g, "\\\\")}'`,
208
- `-ArgumentList 'tunnel','--url','http://localhost:${port}'`,
209
- `-RedirectStandardError '${tunnelLog.replace(/\\/g, "\\\\")}'`,
210
- `; Write-Output $p.Id`,
211
- ].join(" ");
212
- const result = Bun.spawnSync({
213
- cmd: ["powershell", "-NoProfile", "-Command", psCmd],
214
- stdout: "pipe", stderr: "pipe",
215
- });
216
- tunnelPid = parseInt(result.stdout.toString().trim(), 10);
217
- if (isNaN(tunnelPid)) tunnelPid = undefined;
218
- } else {
219
- const tfd = openFd(tunnelLog, "a");
220
- const tunnelProc = Bun.spawn({
221
- cmd: [bin, "tunnel", "--url", `http://localhost:${port}`],
222
- stdio: ["ignore", "ignore", tfd],
223
- env: process.env,
224
- });
225
- tunnelProc.unref();
226
- tunnelPid = tunnelProc.pid;
227
- }
228
-
229
- // Parse URL from tunnel.log (poll stderr output)
230
- const urlRegex = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
231
- const pollStart = Date.now();
232
- while (Date.now() - pollStart < 30_000) {
233
- await Bun.sleep(500);
234
- try {
235
- const logContent = readFileSync(tunnelLog, "utf-8");
236
- const match = logContent.match(urlRegex);
237
- if (match) { shareUrl = match[0]; break; }
238
- } catch {}
239
- }
240
- if (!shareUrl) console.warn(" ⚠ Tunnel started but URL not detected.");
241
- }
214
+ await ensureCloudflared();
242
215
  }
243
216
 
244
- // Write preliminary status.json so child process can read shareUrl on startup
245
- // (child reads this before parent has a chance to write PID — fixes race condition)
246
- writeFileSync(statusFile, JSON.stringify({
247
- port, host,
248
- shareUrl: shareUrl ?? null,
249
- tunnelPid: tunnelPid ?? null,
250
- }));
251
-
252
- // Spawn server child process with log file
253
- const { openSync } = await import("node:fs");
254
- const { isCompiledBinary } = await import("../services/autostart-generator.ts");
217
+ // Spawn supervisor process (manages server + tunnel children)
255
218
  const isCompiledBin = isCompiledBinary();
256
219
  const logFile = resolve(ppmDir, "ppm.log");
257
220
  const logFd = openSync(logFile, "a");
258
- const { resolve: resolvePath } = await import("node:path");
259
- const script = resolvePath(import.meta.dir, "index.ts");
260
- // Keep positional order: port, host, config, profile (empty strings kept as placeholders)
261
- const args = ["__serve__", String(port), host, options.config ?? "", options.profile ?? ""];
262
- // Windows PowerShell: strip trailing empty args to avoid ArgumentList validation error
263
- while (args.length > 0 && args[args.length - 1] === "") args.pop();
221
+ const supervisorScript = resolve(import.meta.dir, "..", "services", "supervisor.ts");
222
+
223
+ const superviseArgs = [
224
+ "__supervise__", String(port), host,
225
+ options.config ?? "", options.profile ?? "",
226
+ ];
227
+ if (options.share) superviseArgs.push("--share");
228
+ // Strip trailing empty args (before --share flag)
229
+ while (superviseArgs.length > 1 && superviseArgs[superviseArgs.length - 1] === "") superviseArgs.pop();
264
230
 
265
- let childPid: number;
231
+ let supervisorPid: number;
266
232
 
267
233
  if (process.platform === "win32") {
268
- // Windows: Bun.spawn child may die when parent exits (same job object).
269
- // Use PowerShell Start-Process to create a truly detached process.
270
234
  const bunExe = process.execPath.replace(/\\/g, "\\\\");
271
235
  const logEscaped = logFile.replace(/\\/g, "\\\\");
272
236
  const errLog = logFile.replace(/\.log$/, ".err.log").replace(/\\/g, "\\\\");
273
- // Use "_" placeholder for empty args PowerShell rejects empty strings in ArgumentList
274
- const winArgs = isCompiledBin ? args : ["run", script, ...args];
237
+ const winArgs = isCompiledBin ? superviseArgs : ["run", supervisorScript, ...superviseArgs];
275
238
  const argStr = winArgs.map((a) => `'${a || "_"}'`).join(",");
276
239
  const psCmd = [
277
240
  `$p = Start-Process -PassThru -WindowStyle Hidden`,
@@ -283,48 +246,73 @@ export async function startServer(options: {
283
246
  ].join(" ");
284
247
  const result = Bun.spawnSync({
285
248
  cmd: ["powershell", "-NoProfile", "-Command", psCmd],
286
- stdout: "pipe",
287
- stderr: "pipe",
249
+ stdout: "pipe", stderr: "pipe",
288
250
  });
289
- childPid = parseInt(result.stdout.toString().trim(), 10);
290
- if (isNaN(childPid)) {
291
- console.error(" ✗ Failed to start daemon on Windows.");
251
+ supervisorPid = parseInt(result.stdout.toString().trim(), 10);
252
+ if (isNaN(supervisorPid)) {
253
+ console.error(" ✗ Failed to start supervisor on Windows.");
292
254
  console.error(` ${result.stderr.toString().trim()}`);
293
255
  console.error(" Try: ppm start -f (foreground mode)");
294
256
  process.exit(1);
295
257
  }
296
258
  } else {
297
- // macOS/Linux: Bun.spawn + unref works fine
298
- // Compiled binary: execPath IS the server, no "run script" needed
299
259
  const cmd = isCompiledBin
300
- ? [process.execPath, ...args]
301
- : [process.execPath, "run", script, ...args];
260
+ ? [process.execPath, ...superviseArgs]
261
+ : [process.execPath, "run", supervisorScript, ...superviseArgs];
302
262
  const child = Bun.spawn({
303
263
  cmd,
304
264
  stdio: ["ignore", logFd, logFd],
305
265
  env: process.env,
306
266
  });
307
267
  child.unref();
308
- childPid = child.pid;
268
+ supervisorPid = child.pid;
309
269
  }
310
270
 
311
- // Verify daemon is alive after brief startup
312
- await Bun.sleep(500);
313
- let alive = false;
314
- try { process.kill(childPid, 0); alive = true; } catch {}
315
- if (!alive) {
316
- console.error(" ✗ Daemon exited immediately after start.");
271
+ // Wait for supervisor to start server child (poll status.json for pid)
272
+ const startWait = Date.now();
273
+ let serverPid: number | null = null;
274
+ while (Date.now() - startWait < 10_000) {
275
+ await Bun.sleep(500);
276
+ // Check supervisor is still alive
277
+ try { process.kill(supervisorPid, 0); } catch {
278
+ console.error(" ✗ Supervisor exited immediately after start.");
279
+ console.error(" Check logs: ppm logs");
280
+ console.error(" Or try: ppm start -f (foreground mode)");
281
+ process.exit(1);
282
+ }
283
+ // Check if server PID appeared in status.json
284
+ try {
285
+ const data = JSON.parse(readFileSync(statusFile, "utf-8"));
286
+ if (data.pid && data.supervisorPid) {
287
+ serverPid = data.pid;
288
+ break;
289
+ }
290
+ } catch {}
291
+ }
292
+
293
+ if (!serverPid) {
294
+ console.error(" ✗ Server did not start within 10 seconds.");
317
295
  console.error(" Check logs: ppm logs");
318
- console.error(" Or try: ppm start -f (foreground mode)");
296
+ try { process.kill(supervisorPid); } catch {}
319
297
  process.exit(1);
320
298
  }
321
299
 
322
- // Update status file with child PID + server script path for restart
323
- const status = { pid: childPid, port, host, shareUrl: shareUrl ?? null, tunnelPid: tunnelPid ?? null, serverScript: script };
324
- writeFileSync(statusFile, JSON.stringify(status));
325
- writeFileSync(pidFile, String(childPid));
300
+ // Read final status for share URL
301
+ let shareUrl: string | null = null;
302
+ if (options.share) {
303
+ // Give tunnel a bit more time to establish
304
+ const tunnelWait = Date.now();
305
+ while (Date.now() - tunnelWait < 35_000) {
306
+ await Bun.sleep(500);
307
+ try {
308
+ const data = JSON.parse(readFileSync(statusFile, "utf-8"));
309
+ if (data.shareUrl) { shareUrl = data.shareUrl; break; }
310
+ } catch {}
311
+ }
312
+ if (!shareUrl) console.warn(" ⚠ Tunnel started but URL not detected yet. Check: ppm status");
313
+ }
326
314
 
327
- console.log(` Daemon started (PID: ${childPid})\n`);
315
+ console.log(` Supervisor started (PID: ${supervisorPid}, server PID: ${serverPid})\n`);
328
316
  console.log(` ➜ Local: http://localhost:${port}/`);
329
317
  if (shareUrl) {
330
318
  console.log(` ➜ Share: ${shareUrl}`);
@@ -0,0 +1,57 @@
1
+ import { Hono } from "hono";
2
+ import { resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { readFileSync, existsSync } from "node:fs";
5
+ import { VERSION } from "../../version.ts";
6
+ import {
7
+ getInstallMethod,
8
+ applyUpgrade,
9
+ signalSupervisorUpgrade,
10
+ } from "../../services/upgrade.service.ts";
11
+ import { ok, err } from "../../types/api.ts";
12
+
13
+ const STATUS_FILE = resolve(process.env.PPM_HOME || resolve(homedir(), ".ppm"), "status.json");
14
+
15
+ export const upgradeRoutes = new Hono();
16
+
17
+ /** GET / — upgrade status (current version, available version, install method) */
18
+ upgradeRoutes.get("/", (c) => {
19
+ let availableVersion: string | null = null;
20
+ try {
21
+ if (existsSync(STATUS_FILE)) {
22
+ const data = JSON.parse(readFileSync(STATUS_FILE, "utf-8"));
23
+ availableVersion = data.availableVersion ?? null;
24
+ }
25
+ } catch {}
26
+
27
+ return c.json(ok({
28
+ currentVersion: VERSION,
29
+ availableVersion,
30
+ installMethod: getInstallMethod(),
31
+ }));
32
+ });
33
+
34
+ /** POST /apply — install latest version + signal supervisor to self-replace */
35
+ upgradeRoutes.post("/apply", async (c) => {
36
+ const result = await applyUpgrade();
37
+ if (!result.success) {
38
+ return c.json(err(result.error ?? "Upgrade failed"), 500);
39
+ }
40
+
41
+ // Signal supervisor to self-replace
42
+ const signal = signalSupervisorUpgrade();
43
+ if (!signal.sent) {
44
+ return c.json(ok({
45
+ success: true,
46
+ newVersion: result.newVersion,
47
+ restart: false,
48
+ message: "Upgraded. Restart manually with ppm restart",
49
+ }));
50
+ }
51
+
52
+ return c.json(ok({
53
+ success: true,
54
+ newVersion: result.newVersion,
55
+ restart: true,
56
+ }));
57
+ });
@@ -90,10 +90,7 @@ ${programArgs}
90
90
  <key>RunAtLoad</key>
91
91
  <true/>
92
92
  <key>KeepAlive</key>
93
- <dict>
94
- <key>SuccessfulExit</key>
95
- <false/>
96
- </dict>
93
+ <true/>
97
94
  <key>StandardOutPath</key>
98
95
  <string>${escapeXml(logPath)}</string>
99
96
  <key>StandardErrorPath</key>
@@ -133,7 +130,7 @@ Wants=network-online.target
133
130
  [Service]
134
131
  Type=simple
135
132
  ExecStart=${execStart}
136
- Restart=on-failure
133
+ Restart=always
137
134
  RestartSec=5
138
135
  ${envPath}
139
136
  WorkingDirectory=${homedir()}/.ppm
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { mkdirSync, existsSync } from "node:fs";
5
5
 
6
- const PPM_DIR = resolve(homedir(), ".ppm");
6
+ const PPM_DIR = process.env.PPM_HOME || resolve(homedir(), ".ppm");
7
7
  const CURRENT_SCHEMA_VERSION = 5;
8
8
 
9
9
  let db: Database | null = null;