@hienlh/ppm 0.7.34 → 0.7.36

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 (25) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/web/assets/{chat-tab-mN5_K1SN.js → chat-tab-BYDRNrD8.js} +1 -1
  3. package/dist/web/assets/{code-editor-CtVQCgrH.js → code-editor-4C6EgzPR.js} +1 -1
  4. package/dist/web/assets/{database-viewer-Clz1Un15.js → database-viewer-59U5HapI.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-D1X1uUpH.js → diff-viewer-CCaAP8X7.js} +1 -1
  6. package/dist/web/assets/{git-graph-Ca9ICEli.js → git-graph-DMC6jCKi.js} +1 -1
  7. package/dist/web/assets/index-Dgwazmyy.js +28 -0
  8. package/dist/web/assets/index-M3zwguMz.css +2 -0
  9. package/dist/web/assets/keybindings-store-QCkzpfSx.js +1 -0
  10. package/dist/web/assets/{markdown-renderer-P4B27yZE.js → markdown-renderer-6j6zXseA.js} +1 -1
  11. package/dist/web/assets/{postgres-viewer-CtCRnfFr.js → postgres-viewer-DHAVUBOr.js} +1 -1
  12. package/dist/web/assets/{settings-tab-C-EIP5gB.js → settings-tab-oCr6rOO2.js} +1 -1
  13. package/dist/web/assets/{sqlite-viewer-DeyFFG69.js → sqlite-viewer-9ikZrlnj.js} +1 -1
  14. package/dist/web/assets/{terminal-tab-Ca3TXosj.js → terminal-tab-FX85YKLG.js} +1 -1
  15. package/dist/web/index.html +2 -2
  16. package/dist/web/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/src/cli/commands/cloud.ts +20 -1
  19. package/src/server/index.ts +4 -0
  20. package/src/server/routes/cloud.ts +108 -0
  21. package/src/web/components/layout/cloud-share-popover.tsx +312 -0
  22. package/src/web/components/layout/project-bar.tsx +19 -172
  23. package/dist/web/assets/index-BCibi3mV.css +0 -2
  24. package/dist/web/assets/index-CvVV2MmZ.js +0 -28
  25. package/dist/web/assets/keybindings-store-DUYtWibd.js +0 -1
@@ -89,7 +89,26 @@ export function registerCloudCommands(program: Command): void {
89
89
  console.log(` ✓ Machine linked`);
90
90
  console.log(` Name: ${device.name}`);
91
91
  console.log(` ID: ${device.device_id}`);
92
- console.log(`\n Run 'ppm start --share' to sync tunnel URL to cloud.\n`);
92
+
93
+ // Auto-detect running tunnel and start heartbeat immediately
94
+ try {
95
+ const { resolve } = await import("node:path");
96
+ const { homedir } = await import("node:os");
97
+ const { existsSync, readFileSync } = await import("node:fs");
98
+ const statusFile = resolve(homedir(), ".ppm", "status.json");
99
+ if (existsSync(statusFile)) {
100
+ const status = JSON.parse(readFileSync(statusFile, "utf-8"));
101
+ if (status.shareUrl) {
102
+ const { sendHeartbeat } = await import("../../services/cloud.service.ts");
103
+ const ok = await sendHeartbeat(status.shareUrl);
104
+ if (ok) {
105
+ console.log(`\n ➜ Cloud: synced tunnel URL (${status.shareUrl})`);
106
+ }
107
+ }
108
+ }
109
+ } catch { /* non-blocking */ }
110
+
111
+ console.log();
93
112
  } catch (err: unknown) {
94
113
  const msg = err instanceof Error ? err.message : String(err);
95
114
  console.error(` ✗ Link failed: ${msg}\n`);
@@ -118,6 +118,10 @@ app.route("/api/postgres", postgresRoutes);
118
118
  app.route("/api/db", databaseRoutes);
119
119
  app.route("/api/accounts", accountsRoutes);
120
120
 
121
+ // Cloud device registry
122
+ import { cloudRoutes } from "./routes/cloud.ts";
123
+ app.route("/api/cloud", cloudRoutes);
124
+
121
125
  // Static files / SPA fallback (non-API routes)
122
126
  app.route("/", staticRoutes);
123
127
 
@@ -0,0 +1,108 @@
1
+ import { Hono } from "hono";
2
+ import { ok, err } from "../../types/api.ts";
3
+ import {
4
+ getCloudAuth,
5
+ getCloudDevice,
6
+ saveCloudAuth,
7
+ removeCloudAuth,
8
+ linkDevice,
9
+ unlinkDevice,
10
+ sendHeartbeat,
11
+ startHeartbeat,
12
+ DEFAULT_CLOUD_URL,
13
+ } from "../../services/cloud.service.ts";
14
+ import { tunnelService } from "../../services/tunnel.service.ts";
15
+ import { configService } from "../../services/config.service.ts";
16
+ import { VERSION } from "../../version.ts";
17
+
18
+ export const cloudRoutes = new Hono();
19
+
20
+ /** GET /api/cloud/status — cloud connection status */
21
+ cloudRoutes.get("/status", (c) => {
22
+ const auth = getCloudAuth();
23
+ const device = getCloudDevice();
24
+ const tunnelUrl = tunnelService.getTunnelUrl();
25
+
26
+ return c.json(ok({
27
+ logged_in: !!auth,
28
+ email: auth?.email ?? null,
29
+ cloud_url: auth?.cloud_url ?? configService.get("cloud_url") ?? DEFAULT_CLOUD_URL,
30
+ linked: !!device,
31
+ device_name: device?.name ?? null,
32
+ device_id: device?.device_id ?? null,
33
+ tunnel_active: !!tunnelUrl,
34
+ tunnel_url: tunnelUrl,
35
+ }));
36
+ });
37
+
38
+ /** POST /api/cloud/login — save cloud auth from web UI OAuth flow */
39
+ cloudRoutes.post("/login", async (c) => {
40
+ const body = await c.req.json<{
41
+ access_token: string;
42
+ email: string;
43
+ cloud_url?: string;
44
+ }>();
45
+
46
+ if (!body.access_token || !body.email) {
47
+ return c.json(err("access_token and email required"), 400);
48
+ }
49
+
50
+ const cloudUrl = body.cloud_url ?? configService.get("cloud_url") ?? DEFAULT_CLOUD_URL;
51
+
52
+ saveCloudAuth({
53
+ access_token: body.access_token,
54
+ refresh_token: "",
55
+ email: body.email,
56
+ cloud_url: cloudUrl,
57
+ saved_at: new Date().toISOString(),
58
+ });
59
+
60
+ return c.json(ok({ email: body.email }));
61
+ });
62
+
63
+ /** POST /api/cloud/logout — remove cloud auth */
64
+ cloudRoutes.post("/logout", (_c) => {
65
+ removeCloudAuth();
66
+ return _c.json(ok(true));
67
+ });
68
+
69
+ /** POST /api/cloud/link — register device with cloud */
70
+ cloudRoutes.post("/link", async (c) => {
71
+ const body = await c.req.json<{ name?: string }>().catch(() => ({}));
72
+
73
+ try {
74
+ const device = await linkDevice(body.name);
75
+
76
+ // Auto-start heartbeat if tunnel is active
77
+ const tunnelUrl = tunnelService.getTunnelUrl();
78
+ if (tunnelUrl) {
79
+ startHeartbeat(tunnelUrl);
80
+ }
81
+
82
+ return c.json(ok({
83
+ device_id: device.device_id,
84
+ name: device.name,
85
+ synced: !!tunnelUrl,
86
+ }));
87
+ } catch (e) {
88
+ return c.json(err((e as Error).message), 500);
89
+ }
90
+ });
91
+
92
+ /** POST /api/cloud/unlink — remove device from cloud */
93
+ cloudRoutes.post("/unlink", async (c) => {
94
+ try {
95
+ await unlinkDevice();
96
+ return c.json(ok(true));
97
+ } catch (e) {
98
+ return c.json(err((e as Error).message), 500);
99
+ }
100
+ });
101
+
102
+ /** GET /api/cloud/login-url — get cloud OAuth login URL for web redirect */
103
+ cloudRoutes.get("/login-url", (c) => {
104
+ const cloudUrl = configService.get("cloud_url") ?? DEFAULT_CLOUD_URL;
105
+ // Web UI opens this URL in a new tab/popup; cloud handles OAuth and returns token
106
+ const loginUrl = `${cloudUrl}/auth/google/login`;
107
+ return c.json(ok({ url: loginUrl, cloud_url: cloudUrl }));
108
+ });
@@ -0,0 +1,312 @@
1
+ import { useState, useCallback, useEffect } from "react";
2
+ import { Cloud, Share2, Loader2, Copy, Check, X, LogOut, Link, Unlink, ExternalLink } from "lucide-react";
3
+ import { QRCodeSVG } from "qrcode.react";
4
+ import { api } from "@/lib/api-client";
5
+
6
+ interface CloudStatus {
7
+ logged_in: boolean;
8
+ email: string | null;
9
+ cloud_url: string;
10
+ linked: boolean;
11
+ device_name: string | null;
12
+ device_id: string | null;
13
+ tunnel_active: boolean;
14
+ tunnel_url: string | null;
15
+ }
16
+
17
+ interface TunnelStatus {
18
+ active: boolean;
19
+ url: string | null;
20
+ localUrl: string | null;
21
+ }
22
+
23
+ interface Props {
24
+ onClose: () => void;
25
+ }
26
+
27
+ export function CloudSharePopover({ onClose }: Props) {
28
+ const [cloud, setCloud] = useState<CloudStatus | null>(null);
29
+ const [tunnel, setTunnel] = useState<TunnelStatus | null>(null);
30
+ const [loading, setLoading] = useState(true);
31
+ const [tunnelStarting, setTunnelStarting] = useState(false);
32
+ const [linking, setLinking] = useState(false);
33
+ const [unlinking, setUnlinking] = useState(false);
34
+ const [error, setError] = useState<string | null>(null);
35
+ const [copied, setCopied] = useState<string | null>(null);
36
+
37
+ // Load status on mount
38
+ useEffect(() => {
39
+ (async () => {
40
+ try {
41
+ const [cloudRes, tunnelRes] = await Promise.all([
42
+ api.get<CloudStatus>("/api/cloud/status"),
43
+ api.get<TunnelStatus>("/api/tunnel"),
44
+ ]);
45
+ setCloud(cloudRes);
46
+ setTunnel(tunnelRes);
47
+ } catch { /* ignore */ }
48
+ setLoading(false);
49
+ })();
50
+ }, []);
51
+
52
+ const handleCopy = useCallback((url: string) => {
53
+ navigator.clipboard.writeText(url);
54
+ setCopied(url);
55
+ setTimeout(() => setCopied(null), 2000);
56
+ }, []);
57
+
58
+ const handleStartTunnel = useCallback(async () => {
59
+ setTunnelStarting(true);
60
+ setError(null);
61
+ try {
62
+ const result = await api.post<{ url: string }>("/api/tunnel/start", {});
63
+ setTunnel({ active: true, url: result.url, localUrl: tunnel?.localUrl ?? null });
64
+ // Refresh cloud status (heartbeat may have started)
65
+ const cs = await api.get<CloudStatus>("/api/cloud/status");
66
+ setCloud(cs);
67
+ } catch (e) {
68
+ setError(e instanceof Error ? e.message : "Failed to start tunnel");
69
+ } finally {
70
+ setTunnelStarting(false);
71
+ }
72
+ }, [tunnel]);
73
+
74
+ const handleCloudLogin = useCallback(async () => {
75
+ try {
76
+ const { url, cloud_url } = await api.get<{ url: string; cloud_url: string }>("/api/cloud/login-url");
77
+ // Open popup for OAuth
78
+ const popup = window.open(url, "ppm-cloud-login", "width=500,height=600,menubar=no,toolbar=no");
79
+
80
+ // Poll for completion — user will be redirected to /dashboard on cloud
81
+ // We check cloud status periodically until logged_in becomes true
82
+ const poll = setInterval(async () => {
83
+ try {
84
+ // Check if cloud set cookies/session
85
+ const res = await fetch(`${cloud_url}/auth/session`, { credentials: "include" });
86
+ if (res.ok) {
87
+ const data = await res.json();
88
+ if (data.user?.email) {
89
+ clearInterval(poll);
90
+ popup?.close();
91
+ // Save auth to PPM server
92
+ await api.post("/api/cloud/login", {
93
+ access_token: "web-session", // web uses cookie-based auth
94
+ email: data.user.email,
95
+ cloud_url,
96
+ });
97
+ const cs = await api.get<CloudStatus>("/api/cloud/status");
98
+ setCloud(cs);
99
+ }
100
+ }
101
+ } catch { /* keep polling */ }
102
+ }, 2000);
103
+
104
+ // Stop polling after 2 minutes
105
+ setTimeout(() => clearInterval(poll), 120_000);
106
+ } catch (e) {
107
+ setError(e instanceof Error ? e.message : "Failed to get login URL");
108
+ }
109
+ }, []);
110
+
111
+ const handleLink = useCallback(async () => {
112
+ setLinking(true);
113
+ setError(null);
114
+ try {
115
+ // If not sharing yet, start tunnel first
116
+ if (!tunnel?.active) {
117
+ const result = await api.post<{ url: string }>("/api/tunnel/start", {});
118
+ setTunnel({ active: true, url: result.url, localUrl: tunnel?.localUrl ?? null });
119
+ }
120
+ const result = await api.post<{ device_id: string; name: string; synced: boolean }>("/api/cloud/link", {});
121
+ const cs = await api.get<CloudStatus>("/api/cloud/status");
122
+ setCloud(cs);
123
+ } catch (e) {
124
+ setError(e instanceof Error ? e.message : "Failed to link device");
125
+ } finally {
126
+ setLinking(false);
127
+ }
128
+ }, [tunnel]);
129
+
130
+ const handleUnlink = useCallback(async () => {
131
+ setUnlinking(true);
132
+ try {
133
+ await api.post("/api/cloud/unlink", {});
134
+ const cs = await api.get<CloudStatus>("/api/cloud/status");
135
+ setCloud(cs);
136
+ } catch { /* ignore */ }
137
+ setUnlinking(false);
138
+ }, []);
139
+
140
+ const handleLogout = useCallback(async () => {
141
+ await api.post("/api/cloud/logout", {});
142
+ setCloud((prev) => prev ? { ...prev, logged_in: false, email: null, linked: false, device_name: null, device_id: null } : null);
143
+ }, []);
144
+
145
+ const shareUrl = tunnel?.url || cloud?.tunnel_url;
146
+
147
+ return (
148
+ <div className="w-72 bg-background border border-border rounded-lg shadow-lg p-3 space-y-3">
149
+ {/* Header */}
150
+ <div className="flex items-center justify-between">
151
+ <div className="flex items-center gap-1.5">
152
+ <Cloud className="size-4 text-primary" />
153
+ <span className="text-sm font-medium text-foreground">PPM Cloud</span>
154
+ </div>
155
+ <button onClick={onClose} className="text-text-subtle hover:text-foreground">
156
+ <X className="size-3.5" />
157
+ </button>
158
+ </div>
159
+
160
+ {loading && (
161
+ <div className="flex items-center gap-2 text-muted-foreground text-xs py-2">
162
+ <Loader2 className="size-4 animate-spin" />
163
+ <span>Loading...</span>
164
+ </div>
165
+ )}
166
+
167
+ {!loading && (
168
+ <>
169
+ {/* Cloud Account Section */}
170
+ <div className="space-y-1.5">
171
+ {cloud?.logged_in ? (
172
+ <div className="flex items-center justify-between">
173
+ <div className="flex items-center gap-1.5 min-w-0">
174
+ <div className="size-2 rounded-full bg-green-500 shrink-0" />
175
+ <span className="text-xs text-foreground truncate">{cloud.email}</span>
176
+ </div>
177
+ <button
178
+ onClick={handleLogout}
179
+ className="text-text-subtle hover:text-foreground p-1 rounded hover:bg-muted transition-colors shrink-0"
180
+ title="Sign out"
181
+ >
182
+ <LogOut className="size-3" />
183
+ </button>
184
+ </div>
185
+ ) : (
186
+ <button
187
+ onClick={handleCloudLogin}
188
+ className="w-full flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium rounded-md border border-border hover:bg-muted transition-colors"
189
+ >
190
+ <Cloud className="size-3.5" />
191
+ Sign in to PPM Cloud
192
+ </button>
193
+ )}
194
+ </div>
195
+
196
+ {/* Device Link Section */}
197
+ {cloud?.logged_in && (
198
+ <div className="space-y-1.5">
199
+ {cloud.linked ? (
200
+ <div className="flex items-center justify-between text-xs">
201
+ <div className="flex items-center gap-1.5 min-w-0">
202
+ <Link className="size-3 text-primary shrink-0" />
203
+ <span className="text-foreground truncate">{cloud.device_name}</span>
204
+ </div>
205
+ <button
206
+ onClick={handleUnlink}
207
+ disabled={unlinking}
208
+ className="text-text-subtle hover:text-destructive p-1 rounded hover:bg-muted transition-colors shrink-0"
209
+ title="Unlink device"
210
+ >
211
+ {unlinking ? <Loader2 className="size-3 animate-spin" /> : <Unlink className="size-3" />}
212
+ </button>
213
+ </div>
214
+ ) : (
215
+ <button
216
+ onClick={handleLink}
217
+ disabled={linking}
218
+ className="w-full flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
219
+ >
220
+ {linking ? (
221
+ <><Loader2 className="size-3.5 animate-spin" /> Linking...</>
222
+ ) : (
223
+ <><Link className="size-3.5" /> Link this machine</>
224
+ )}
225
+ </button>
226
+ )}
227
+ </div>
228
+ )}
229
+
230
+ {/* Separator */}
231
+ <div className="border-t border-border" />
232
+
233
+ {/* Local Network URL */}
234
+ {tunnel?.localUrl && (
235
+ <div className="space-y-1">
236
+ <span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Local Network</span>
237
+ <UrlRow url={tunnel.localUrl} copied={copied} onCopy={handleCopy} />
238
+ </div>
239
+ )}
240
+
241
+ {/* Tunnel / Share Section */}
242
+ {!shareUrl && !tunnelStarting && (
243
+ <button
244
+ onClick={handleStartTunnel}
245
+ className="w-full flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
246
+ >
247
+ <Share2 className="size-3.5" />
248
+ Start Sharing
249
+ </button>
250
+ )}
251
+
252
+ {tunnelStarting && (
253
+ <div className="flex flex-col items-center gap-2 py-2">
254
+ <Loader2 className="size-5 animate-spin text-primary" />
255
+ <span className="text-xs text-muted-foreground">Starting tunnel...</span>
256
+ </div>
257
+ )}
258
+
259
+ {shareUrl && (
260
+ <div className="space-y-1.5">
261
+ <span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Public URL</span>
262
+ <div className="flex justify-center">
263
+ <div className="bg-white p-2 rounded">
264
+ <QRCodeSVG value={shareUrl} size={140} bgColor="#ffffff" fgColor="#000000" />
265
+ </div>
266
+ </div>
267
+ <UrlRow url={shareUrl} copied={copied} onCopy={handleCopy} />
268
+ </div>
269
+ )}
270
+
271
+ {/* Cloud Dashboard Link */}
272
+ {cloud?.logged_in && cloud.linked && (
273
+ <a
274
+ href={cloud.cloud_url + "/dashboard"}
275
+ target="_blank"
276
+ rel="noopener noreferrer"
277
+ className="flex items-center justify-center gap-1.5 w-full px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground rounded-md border border-border hover:bg-muted transition-colors"
278
+ >
279
+ <ExternalLink className="size-3" />
280
+ Open Cloud Dashboard
281
+ </a>
282
+ )}
283
+
284
+ {/* Error */}
285
+ {error && (
286
+ <p className="text-xs text-destructive">{error}</p>
287
+ )}
288
+ </>
289
+ )}
290
+ </div>
291
+ );
292
+ }
293
+
294
+ function UrlRow({ url, copied, onCopy }: { url: string; copied: string | null; onCopy: (u: string) => void }) {
295
+ return (
296
+ <div className="flex items-center gap-1">
297
+ <input
298
+ readOnly
299
+ value={url}
300
+ className="flex-1 text-xs font-mono text-foreground bg-muted px-2 py-1.5 rounded border border-border truncate"
301
+ onClick={(e) => (e.target as HTMLInputElement).select()}
302
+ />
303
+ <button
304
+ onClick={() => onCopy(url)}
305
+ className="flex items-center justify-center size-7 rounded border border-border text-muted-foreground bg-muted hover:bg-accent hover:text-foreground transition-colors shrink-0"
306
+ title="Copy URL"
307
+ >
308
+ {copied === url ? <Check className="size-3.5 text-primary" /> : <Copy className="size-3.5" />}
309
+ </button>
310
+ </div>
311
+ );
312
+ }