@hienlh/ppm 0.7.31 → 0.7.32
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 +12 -0
- package/package.json +1 -1
- package/src/cli/commands/cloud.ts +192 -0
- package/src/index.ts +3 -0
- package/src/server/index.ts +14 -0
- package/src/services/cloud.service.ts +345 -0
- package/src/types/config.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.7.32] - 2026-03-23
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **PPM Cloud CLI**: `ppm cloud login/logout/link/unlink/status/devices` — connect PPM instances to PPM Cloud for device registry + tunnel URL sync
|
|
7
|
+
- **Auto-sync heartbeat**: `ppm start --share` automatically syncs tunnel URL to cloud every 5 minutes if device is linked
|
|
8
|
+
- **Cloud URL config**: `ppm config set cloud_url <url>` to use custom cloud instance
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
- Cloud auth files (`cloud-auth.json`, `cloud-device.json`) restricted to owner-only (chmod 0o600)
|
|
12
|
+
- XSS prevention in OAuth callback HTML
|
|
13
|
+
- Heartbeat interval cleanup prevents duplicate timers on restart
|
|
14
|
+
|
|
3
15
|
## [0.7.31] - 2026-03-23
|
|
4
16
|
|
|
5
17
|
### Improved
|
package/package.json
CHANGED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
|
|
3
|
+
export function registerCloudCommands(program: Command): void {
|
|
4
|
+
const cmd = program
|
|
5
|
+
.command("cloud")
|
|
6
|
+
.description("PPM Cloud — device registry + tunnel URL sync");
|
|
7
|
+
|
|
8
|
+
cmd
|
|
9
|
+
.command("login")
|
|
10
|
+
.description("Sign in with Google (opens browser)")
|
|
11
|
+
.option("--url <url>", "Cloud URL override")
|
|
12
|
+
.action(async (options) => {
|
|
13
|
+
const {
|
|
14
|
+
startLoginServer,
|
|
15
|
+
getCloudAuth,
|
|
16
|
+
DEFAULT_CLOUD_URL,
|
|
17
|
+
} = await import("../../services/cloud.service.ts");
|
|
18
|
+
const { configService } = await import("../../services/config.service.ts");
|
|
19
|
+
|
|
20
|
+
const cloudUrl =
|
|
21
|
+
options.url ||
|
|
22
|
+
configService.get("cloud_url") ||
|
|
23
|
+
DEFAULT_CLOUD_URL;
|
|
24
|
+
|
|
25
|
+
// Check if already logged in
|
|
26
|
+
const existing = getCloudAuth();
|
|
27
|
+
if (existing) {
|
|
28
|
+
console.log(` Already logged in as ${existing.email}`);
|
|
29
|
+
console.log(` Run 'ppm cloud logout' to switch accounts.\n`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const auth = await startLoginServer(cloudUrl);
|
|
35
|
+
console.log(` ✓ Logged in as ${auth.email}\n`);
|
|
36
|
+
console.log(` Next: run 'ppm cloud link' to register this machine.\n`);
|
|
37
|
+
} catch (err: unknown) {
|
|
38
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
39
|
+
console.error(` ✗ Login failed: ${msg}\n`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
cmd
|
|
45
|
+
.command("logout")
|
|
46
|
+
.description("Sign out from PPM Cloud")
|
|
47
|
+
.action(async () => {
|
|
48
|
+
const { removeCloudAuth, getCloudAuth } = await import(
|
|
49
|
+
"../../services/cloud.service.ts"
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const auth = getCloudAuth();
|
|
53
|
+
removeCloudAuth();
|
|
54
|
+
if (auth) {
|
|
55
|
+
console.log(` ✓ Logged out (was: ${auth.email})\n`);
|
|
56
|
+
} else {
|
|
57
|
+
console.log(` Not logged in.\n`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
cmd
|
|
62
|
+
.command("link")
|
|
63
|
+
.description("Register this machine with PPM Cloud")
|
|
64
|
+
.option("-n, --name <name>", "Machine display name")
|
|
65
|
+
.action(async (options) => {
|
|
66
|
+
const { linkDevice } = await import("../../services/cloud.service.ts");
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const device = await linkDevice(options.name);
|
|
70
|
+
console.log(` ✓ Machine linked`);
|
|
71
|
+
console.log(` Name: ${device.name}`);
|
|
72
|
+
console.log(` ID: ${device.device_id}`);
|
|
73
|
+
console.log(`\n Run 'ppm start --share' to sync tunnel URL to cloud.\n`);
|
|
74
|
+
} catch (err: unknown) {
|
|
75
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
76
|
+
console.error(` ✗ Link failed: ${msg}\n`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
cmd
|
|
82
|
+
.command("unlink")
|
|
83
|
+
.description("Remove this machine from PPM Cloud")
|
|
84
|
+
.action(async () => {
|
|
85
|
+
const { unlinkDevice, getCloudDevice } = await import(
|
|
86
|
+
"../../services/cloud.service.ts"
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const device = getCloudDevice();
|
|
90
|
+
if (!device) {
|
|
91
|
+
console.log(` Not linked to cloud.\n`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await unlinkDevice();
|
|
97
|
+
console.log(` ✓ Machine unlinked (was: ${device.name})\n`);
|
|
98
|
+
} catch (err: unknown) {
|
|
99
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
100
|
+
console.error(` ✗ Unlink failed: ${msg}\n`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
cmd
|
|
106
|
+
.command("status")
|
|
107
|
+
.description("Show PPM Cloud connection status")
|
|
108
|
+
.option("--json", "Output as JSON")
|
|
109
|
+
.action(async (options) => {
|
|
110
|
+
const { getCloudAuth, getCloudDevice } = await import(
|
|
111
|
+
"../../services/cloud.service.ts"
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const auth = getCloudAuth();
|
|
115
|
+
const device = getCloudDevice();
|
|
116
|
+
|
|
117
|
+
if (options.json) {
|
|
118
|
+
console.log(
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
logged_in: !!auth,
|
|
121
|
+
email: auth?.email ?? null,
|
|
122
|
+
cloud_url: auth?.cloud_url ?? null,
|
|
123
|
+
linked: !!device,
|
|
124
|
+
device_name: device?.name ?? null,
|
|
125
|
+
device_id: device?.device_id ?? null,
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log(`\n PPM Cloud status\n`);
|
|
132
|
+
|
|
133
|
+
if (auth) {
|
|
134
|
+
console.log(` Logged in: ${auth.email}`);
|
|
135
|
+
console.log(` Cloud URL: ${auth.cloud_url}`);
|
|
136
|
+
} else {
|
|
137
|
+
console.log(` Logged in: no`);
|
|
138
|
+
console.log(` Run 'ppm cloud login' to sign in.`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (device) {
|
|
142
|
+
console.log(` Machine: ${device.name} (${device.device_id.slice(0, 8)}...)`);
|
|
143
|
+
console.log(` Linked at: ${device.linked_at}`);
|
|
144
|
+
} else {
|
|
145
|
+
console.log(` Machine: not linked`);
|
|
146
|
+
if (auth) console.log(` Run 'ppm cloud link' to register this machine.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
cmd
|
|
153
|
+
.command("devices")
|
|
154
|
+
.description("List all registered devices from cloud")
|
|
155
|
+
.option("--json", "Output as JSON")
|
|
156
|
+
.action(async (options) => {
|
|
157
|
+
const { listDevices } = await import("../../services/cloud.service.ts");
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const devices = await listDevices();
|
|
161
|
+
|
|
162
|
+
if (options.json) {
|
|
163
|
+
console.log(JSON.stringify({ devices }));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (devices.length === 0) {
|
|
168
|
+
console.log(` No devices registered.\n`);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log(`\n PPM Cloud devices (${devices.length})\n`);
|
|
173
|
+
for (const d of devices) {
|
|
174
|
+
const status = d.computedStatus === "online" ? "● online " : "○ offline";
|
|
175
|
+
const url = d.tunnelUrl || "(no tunnel)";
|
|
176
|
+
const lastSeen = d.lastHeartbeat
|
|
177
|
+
? new Date(d.lastHeartbeat).toLocaleString()
|
|
178
|
+
: "never";
|
|
179
|
+
console.log(` ${d.name}`);
|
|
180
|
+
console.log(` Status: ${status}`);
|
|
181
|
+
console.log(` Tunnel: ${url}`);
|
|
182
|
+
console.log(` Last seen: ${lastSeen}`);
|
|
183
|
+
console.log(` Version: ${d.version || "unknown"}`);
|
|
184
|
+
console.log();
|
|
185
|
+
}
|
|
186
|
+
} catch (err: unknown) {
|
|
187
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
188
|
+
console.error(` ✗ Failed: ${msg}\n`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -124,4 +124,7 @@ registerChatCommands(program);
|
|
|
124
124
|
const { registerAutoStartCommands } = await import("./cli/commands/autostart.ts");
|
|
125
125
|
registerAutoStartCommands(program);
|
|
126
126
|
|
|
127
|
+
const { registerCloudCommands } = await import("./cli/commands/cloud.ts");
|
|
128
|
+
registerCloudCommands(program);
|
|
129
|
+
|
|
127
130
|
program.parse();
|
package/src/server/index.ts
CHANGED
|
@@ -412,6 +412,13 @@ export async function startServer(options: {
|
|
|
412
412
|
const qr = await import("qrcode-terminal");
|
|
413
413
|
console.log();
|
|
414
414
|
qr.generate(shareUrl, { small: true });
|
|
415
|
+
|
|
416
|
+
// Auto-sync tunnel URL to PPM Cloud (if linked)
|
|
417
|
+
import("../services/cloud.service.ts")
|
|
418
|
+
.then(({ startHeartbeat, getCloudDevice }) => {
|
|
419
|
+
if (getCloudDevice()) startHeartbeat(shareUrl);
|
|
420
|
+
})
|
|
421
|
+
.catch(() => {});
|
|
415
422
|
} catch (err: unknown) {
|
|
416
423
|
const msg = err instanceof Error ? err.message : String(err);
|
|
417
424
|
console.error(` ✗ Share failed: ${msg}`);
|
|
@@ -470,6 +477,13 @@ if (process.argv.includes("__serve__")) {
|
|
|
470
477
|
const { tunnelService } = await import("../services/tunnel.service.ts");
|
|
471
478
|
tunnelService.setExternalUrl(status.shareUrl);
|
|
472
479
|
if (status.tunnelPid) tunnelService.setExternalPid(status.tunnelPid);
|
|
480
|
+
|
|
481
|
+
// Auto-sync tunnel URL to PPM Cloud (daemon mode)
|
|
482
|
+
import("../services/cloud.service.ts")
|
|
483
|
+
.then(({ startHeartbeat, getCloudDevice }) => {
|
|
484
|
+
if (getCloudDevice()) startHeartbeat(status.shareUrl);
|
|
485
|
+
})
|
|
486
|
+
.catch(() => {});
|
|
473
487
|
}
|
|
474
488
|
} catch { /* status.json missing or no shareUrl — normal */ }
|
|
475
489
|
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { homedir, hostname } from "node:os";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from "node:fs";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { VERSION } from "../version.ts";
|
|
6
|
+
|
|
7
|
+
const PPM_DIR = resolve(homedir(), ".ppm");
|
|
8
|
+
const AUTH_FILE = resolve(PPM_DIR, "cloud-auth.json");
|
|
9
|
+
const DEVICE_FILE = resolve(PPM_DIR, "cloud-device.json");
|
|
10
|
+
const MACHINE_ID_FILE = resolve(PPM_DIR, "machine-id");
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CLOUD_URL = "https://ppm.hienle.tech";
|
|
13
|
+
const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
14
|
+
|
|
15
|
+
// ─── Types ──────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
interface CloudAuth {
|
|
18
|
+
access_token: string;
|
|
19
|
+
refresh_token: string;
|
|
20
|
+
email: string;
|
|
21
|
+
cloud_url: string;
|
|
22
|
+
saved_at: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface CloudDevice {
|
|
26
|
+
device_id: string;
|
|
27
|
+
secret_key: string;
|
|
28
|
+
name: string;
|
|
29
|
+
machine_id: string;
|
|
30
|
+
cloud_url: string;
|
|
31
|
+
linked_at: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface DeviceInfo {
|
|
35
|
+
id: string;
|
|
36
|
+
machineId: string;
|
|
37
|
+
name: string;
|
|
38
|
+
tunnelUrl: string | null;
|
|
39
|
+
version: string | null;
|
|
40
|
+
lastHeartbeat: string | null;
|
|
41
|
+
computedStatus: string;
|
|
42
|
+
createdAt: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── Machine ID ─────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/** Get or generate a stable machine ID (random UUID, persists across reboots) */
|
|
48
|
+
export function getMachineId(): string {
|
|
49
|
+
if (existsSync(MACHINE_ID_FILE)) {
|
|
50
|
+
return readFileSync(MACHINE_ID_FILE, "utf-8").trim();
|
|
51
|
+
}
|
|
52
|
+
const id = randomBytes(16).toString("hex");
|
|
53
|
+
ensurePpmDir();
|
|
54
|
+
writeFileSync(MACHINE_ID_FILE, id);
|
|
55
|
+
return id;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Auth ───────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/** Read saved cloud auth credentials */
|
|
61
|
+
export function getCloudAuth(): CloudAuth | null {
|
|
62
|
+
try {
|
|
63
|
+
if (!existsSync(AUTH_FILE)) return null;
|
|
64
|
+
return JSON.parse(readFileSync(AUTH_FILE, "utf-8"));
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Save cloud auth credentials (restricted permissions) */
|
|
71
|
+
export function saveCloudAuth(auth: CloudAuth): void {
|
|
72
|
+
ensurePpmDir();
|
|
73
|
+
writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
|
|
74
|
+
try { chmodSync(AUTH_FILE, 0o600); } catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Remove cloud auth credentials */
|
|
78
|
+
export function removeCloudAuth(): void {
|
|
79
|
+
try { if (existsSync(AUTH_FILE)) unlinkSync(AUTH_FILE); } catch {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Device ─────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/** Read saved cloud device info */
|
|
85
|
+
export function getCloudDevice(): CloudDevice | null {
|
|
86
|
+
try {
|
|
87
|
+
if (!existsSync(DEVICE_FILE)) return null;
|
|
88
|
+
return JSON.parse(readFileSync(DEVICE_FILE, "utf-8"));
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Save cloud device info (restricted permissions) */
|
|
95
|
+
export function saveCloudDevice(device: CloudDevice): void {
|
|
96
|
+
ensurePpmDir();
|
|
97
|
+
writeFileSync(DEVICE_FILE, JSON.stringify(device, null, 2));
|
|
98
|
+
try { chmodSync(DEVICE_FILE, 0o600); } catch {}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Remove cloud device info */
|
|
102
|
+
export function removeCloudDevice(): void {
|
|
103
|
+
try { if (existsSync(DEVICE_FILE)) unlinkSync(DEVICE_FILE); } catch {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── API Client ─────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/** Make authenticated request to cloud API */
|
|
109
|
+
async function cloudFetch(
|
|
110
|
+
path: string,
|
|
111
|
+
options: RequestInit = {},
|
|
112
|
+
auth?: CloudAuth,
|
|
113
|
+
): Promise<Response> {
|
|
114
|
+
const creds = auth || getCloudAuth();
|
|
115
|
+
if (!creds) throw new Error("Not logged in. Run 'ppm cloud login' first.");
|
|
116
|
+
|
|
117
|
+
const url = `${creds.cloud_url}${path}`;
|
|
118
|
+
const headers = new Headers(options.headers);
|
|
119
|
+
headers.set("Authorization", `Bearer ${creds.access_token}`);
|
|
120
|
+
headers.set("Content-Type", "application/json");
|
|
121
|
+
|
|
122
|
+
let res = await fetch(url, { ...options, headers });
|
|
123
|
+
|
|
124
|
+
// Auto-refresh if 401
|
|
125
|
+
if (res.status === 401 && creds.refresh_token) {
|
|
126
|
+
const refreshed = await refreshAccessToken(creds);
|
|
127
|
+
if (refreshed) {
|
|
128
|
+
headers.set("Authorization", `Bearer ${refreshed.access_token}`);
|
|
129
|
+
res = await fetch(url, { ...options, headers });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return res;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Refresh access token — forces re-login for now (refresh endpoint uses cookies, not CLI-friendly) */
|
|
137
|
+
async function refreshAccessToken(_auth: CloudAuth): Promise<CloudAuth | null> {
|
|
138
|
+
// TODO: extend cloud API /auth/refresh to return tokens in response body for CLI
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── CLI Login ──────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Start a temporary localhost server to catch the OAuth callback.
|
|
146
|
+
* Returns the auth credentials after successful login.
|
|
147
|
+
*/
|
|
148
|
+
export async function startLoginServer(cloudUrl: string): Promise<CloudAuth> {
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const timeout = setTimeout(() => {
|
|
151
|
+
server.stop();
|
|
152
|
+
reject(new Error("Login timed out after 120 seconds"));
|
|
153
|
+
}, 120_000);
|
|
154
|
+
|
|
155
|
+
const server = Bun.serve({
|
|
156
|
+
port: 0, // random available port
|
|
157
|
+
fetch(req) {
|
|
158
|
+
const url = new URL(req.url);
|
|
159
|
+
if (url.pathname === "/callback") {
|
|
160
|
+
const accessToken = url.searchParams.get("access_token");
|
|
161
|
+
const refreshToken = url.searchParams.get("refresh_token");
|
|
162
|
+
const email = url.searchParams.get("email");
|
|
163
|
+
|
|
164
|
+
if (!accessToken || !email) {
|
|
165
|
+
clearTimeout(timeout);
|
|
166
|
+
server.stop();
|
|
167
|
+
reject(new Error("OAuth callback missing required parameters"));
|
|
168
|
+
return new Response(errorHtml("Login failed — missing parameters"), {
|
|
169
|
+
headers: { "Content-Type": "text/html" },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const auth: CloudAuth = {
|
|
174
|
+
access_token: accessToken,
|
|
175
|
+
refresh_token: refreshToken || "",
|
|
176
|
+
email,
|
|
177
|
+
cloud_url: cloudUrl,
|
|
178
|
+
saved_at: new Date().toISOString(),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
saveCloudAuth(auth);
|
|
182
|
+
clearTimeout(timeout);
|
|
183
|
+
// Delay stop to allow response to be sent
|
|
184
|
+
setTimeout(() => server.stop(), 500);
|
|
185
|
+
resolve(auth);
|
|
186
|
+
|
|
187
|
+
return new Response(successHtml(email), {
|
|
188
|
+
headers: { "Content-Type": "text/html" },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return new Response("Not found", { status: 404 });
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Open browser with cli_port param
|
|
196
|
+
const loginUrl = `${cloudUrl}/auth/google/login?cli_port=${server.port}`;
|
|
197
|
+
openBrowser(loginUrl);
|
|
198
|
+
console.log(`\n Waiting for Google login...`);
|
|
199
|
+
console.log(` If browser didn't open, visit: ${loginUrl}\n`);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── Device Registration ────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/** Register or re-register this machine with cloud */
|
|
206
|
+
export async function linkDevice(name?: string): Promise<CloudDevice> {
|
|
207
|
+
const auth = getCloudAuth();
|
|
208
|
+
if (!auth) throw new Error("Not logged in. Run 'ppm cloud login' first.");
|
|
209
|
+
|
|
210
|
+
const machineId = getMachineId();
|
|
211
|
+
const deviceName = name || hostname() || "Unknown Machine";
|
|
212
|
+
|
|
213
|
+
const res = await cloudFetch("/api/devices", {
|
|
214
|
+
method: "POST",
|
|
215
|
+
body: JSON.stringify({
|
|
216
|
+
machine_id: machineId,
|
|
217
|
+
name: deviceName,
|
|
218
|
+
version: VERSION,
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
if (!res.ok) {
|
|
223
|
+
const err = await res.text();
|
|
224
|
+
throw new Error(`Failed to register device: ${err}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const data = await res.json() as { id: string; secret_key: string };
|
|
228
|
+
|
|
229
|
+
const device: CloudDevice = {
|
|
230
|
+
device_id: data.id,
|
|
231
|
+
secret_key: data.secret_key,
|
|
232
|
+
name: deviceName,
|
|
233
|
+
machine_id: machineId,
|
|
234
|
+
cloud_url: auth.cloud_url,
|
|
235
|
+
linked_at: new Date().toISOString(),
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
saveCloudDevice(device);
|
|
239
|
+
return device;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Unlink this machine from cloud */
|
|
243
|
+
export async function unlinkDevice(): Promise<void> {
|
|
244
|
+
const device = getCloudDevice();
|
|
245
|
+
if (!device) {
|
|
246
|
+
removeCloudDevice();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
await cloudFetch(`/api/devices/${device.device_id}`, { method: "DELETE" });
|
|
252
|
+
} catch {
|
|
253
|
+
// Continue even if cloud unreachable — clean up local state
|
|
254
|
+
}
|
|
255
|
+
removeCloudDevice();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** List all devices for the logged-in user */
|
|
259
|
+
export async function listDevices(): Promise<DeviceInfo[]> {
|
|
260
|
+
const res = await cloudFetch("/api/devices");
|
|
261
|
+
if (!res.ok) throw new Error(`Failed to list devices: ${res.status}`);
|
|
262
|
+
const data = await res.json() as { devices: DeviceInfo[] };
|
|
263
|
+
return data.devices;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── Heartbeat ──────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/** Send a single heartbeat to cloud (non-blocking, logs errors) */
|
|
269
|
+
export async function sendHeartbeat(tunnelUrl: string): Promise<boolean> {
|
|
270
|
+
const device = getCloudDevice();
|
|
271
|
+
if (!device) return false;
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const res = await fetch(`${device.cloud_url}/api/devices/${device.device_id}/heartbeat`, {
|
|
275
|
+
method: "POST",
|
|
276
|
+
headers: { "Content-Type": "application/json" },
|
|
277
|
+
body: JSON.stringify({
|
|
278
|
+
secret_key: device.secret_key,
|
|
279
|
+
tunnel_url: tunnelUrl,
|
|
280
|
+
status: "online",
|
|
281
|
+
}),
|
|
282
|
+
});
|
|
283
|
+
return res.ok;
|
|
284
|
+
} catch {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
290
|
+
|
|
291
|
+
/** Start periodic heartbeat (call once after tunnel URL is obtained) */
|
|
292
|
+
export function startHeartbeat(tunnelUrl: string): void {
|
|
293
|
+
// Clear any existing heartbeat to prevent duplicates on restart
|
|
294
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
295
|
+
|
|
296
|
+
// Initial heartbeat immediately
|
|
297
|
+
sendHeartbeat(tunnelUrl).then((ok) => {
|
|
298
|
+
if (ok) console.log(" ➜ Cloud: synced to PPM Cloud");
|
|
299
|
+
else console.warn(" ⚠ Cloud sync failed (non-blocking)");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Periodic heartbeat every 5 minutes
|
|
303
|
+
heartbeatTimer = setInterval(() => {
|
|
304
|
+
sendHeartbeat(tunnelUrl).catch(() => {});
|
|
305
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Stop periodic heartbeat */
|
|
309
|
+
export function stopHeartbeat(): void {
|
|
310
|
+
if (heartbeatTimer) {
|
|
311
|
+
clearInterval(heartbeatTimer);
|
|
312
|
+
heartbeatTimer = null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
function ensurePpmDir(): void {
|
|
319
|
+
if (!existsSync(PPM_DIR)) mkdirSync(PPM_DIR, { recursive: true });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function openBrowser(url: string): void {
|
|
323
|
+
try {
|
|
324
|
+
if (process.platform === "darwin") {
|
|
325
|
+
Bun.spawn(["open", url], { stdout: "ignore", stderr: "ignore" });
|
|
326
|
+
} else if (process.platform === "win32") {
|
|
327
|
+
Bun.spawn(["cmd", "/c", "start", url], { stdout: "ignore", stderr: "ignore" });
|
|
328
|
+
} else {
|
|
329
|
+
Bun.spawn(["xdg-open", url], { stdout: "ignore", stderr: "ignore" });
|
|
330
|
+
}
|
|
331
|
+
} catch {}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function successHtml(email: string): string {
|
|
335
|
+
const safe = email.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
336
|
+
return `<!DOCTYPE html><html><body style="font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#0a0a0a;color:#fff">
|
|
337
|
+
<div style="text-align:center"><h1>✓ Logged in</h1><p>Logged in as <b>${safe}</b></p><p style="color:#888">You can close this tab and return to the terminal.</p></div></body></html>`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function errorHtml(message: string): string {
|
|
341
|
+
return `<!DOCTYPE html><html><body style="font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#0a0a0a;color:#fff">
|
|
342
|
+
<div style="text-align:center"><h1>✗ Login Failed</h1><p>${message}</p></div></body></html>`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export { DEFAULT_CLOUD_URL, HEARTBEAT_INTERVAL_MS };
|