@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.
- package/.claude.bak/agent-memory/tester/MEMORY.md +3 -0
- package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/CHANGELOG.md +39 -0
- package/dist/web/assets/{chat-tab-wunayDmr.js → chat-tab-CgVh-OsO.js} +1 -1
- package/dist/web/assets/{code-editor-Fw_VrmHT.js → code-editor-DgvZlpB7.js} +1 -1
- package/dist/web/assets/{database-viewer-CZjxdELm.js → database-viewer-CRZksTo-.js} +1 -1
- package/dist/web/assets/{diff-viewer-B51YfMeK.js → diff-viewer-CPNLuddT.js} +1 -1
- package/dist/web/assets/{git-graph-fCVmtbaj.js → git-graph-BCtMSQwB.js} +1 -1
- package/dist/web/assets/index-CfSJP_Fv.css +2 -0
- package/dist/web/assets/index-DcJqqWbL.js +37 -0
- package/dist/web/assets/keybindings-store-C1HiSDRb.js +1 -0
- package/dist/web/assets/{markdown-renderer-D_OeJdOH.js → markdown-renderer-Ci7qz558.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BlEIES7N.js → postgres-viewer-C8PRJ87B.js} +1 -1
- package/dist/web/assets/{settings-tab-DnU5t6Fy.js → settings-tab-CqnP28Dq.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-BJ2s8Dng.js → sqlite-viewer-BSceyudC.js} +1 -1
- package/dist/web/assets/{terminal-tab-DAFbT7Sv.js → terminal-tab-0Y48dynP.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/project-changelog.md +78 -0
- package/docs/project-roadmap.md +1 -0
- package/docs/system-architecture.md +13 -1
- package/package.json +1 -1
- package/src/cli/commands/restart.ts +39 -0
- package/src/cli/commands/status.ts +10 -0
- package/src/cli/commands/stop.ts +34 -16
- package/src/cli/commands/upgrade.ts +49 -0
- package/src/index.ts +9 -0
- package/src/server/index.ts +100 -112
- package/src/server/routes/upgrade.ts +57 -0
- package/src/services/autostart-generator.ts +2 -5
- package/src/services/db.service.ts +1 -1
- package/src/services/supervisor.ts +469 -0
- package/src/services/upgrade.service.ts +115 -0
- package/src/web/app.tsx +4 -0
- package/src/web/components/layout/upgrade-banner.tsx +102 -0
- package/dist/web/assets/index-CoyMn-Mj.css +0 -2
- package/dist/web/assets/index-DMlEKjZt.js +0 -37
- package/dist/web/assets/keybindings-store-BzXZa5uC.js +0 -1
- package/snapshot-state.md +0 -1526
- package/test-tokens.mjs +0 -212
package/src/cli/commands/stop.ts
CHANGED
|
@@ -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
|
|
6
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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)");
|
|
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 + ${
|
|
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
|
|
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
|
|
98
|
-
if (
|
|
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
|
|
101
|
-
if (
|
|
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
|
|
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
|
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
75
|
+
handleFatalError("Uncaught exception", err.stack ?? err.message);
|
|
59
76
|
});
|
|
60
77
|
process.on("unhandledRejection", (reason) => {
|
|
61
|
-
|
|
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
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
if (isNaN(
|
|
291
|
-
console.error(" ✗ Failed to start
|
|
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, ...
|
|
301
|
-
: [process.execPath, "run",
|
|
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
|
-
|
|
268
|
+
supervisorPid = child.pid;
|
|
309
269
|
}
|
|
310
270
|
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
let
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
296
|
+
try { process.kill(supervisorPid); } catch {}
|
|
319
297
|
process.exit(1);
|
|
320
298
|
}
|
|
321
299
|
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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(`
|
|
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
|
-
<
|
|
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=
|
|
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;
|