@hienlh/ppm 0.7.33 → 0.7.35

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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.35] - 2026-03-23
4
+
5
+ ### Improved
6
+ - **Cloud link auto-sync**: `ppm cloud link` detects active tunnel and sends heartbeat immediately — device shows online on cloud dashboard without restart
7
+
8
+ ## [0.7.34] - 2026-03-23
9
+
10
+ ### Added
11
+ - **Device code login**: `ppm cloud login --device-code` for remote terminals (PPM terminal, SSH, headless). Enter 6-char code at ppm.hienle.tech/verify from any browser.
12
+ - **Auto-detection**: CLI auto-picks browser flow on desktop, device code flow on remote sessions. Falls back to device code if browser fails.
13
+
3
14
  ## [0.7.33] - 2026-03-23
4
15
 
5
16
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.7.33",
3
+ "version": "0.7.35",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -7,11 +7,13 @@ export function registerCloudCommands(program: Command): void {
7
7
 
8
8
  cmd
9
9
  .command("login")
10
- .description("Sign in with Google (opens browser)")
10
+ .description("Sign in with Google")
11
11
  .option("--url <url>", "Cloud URL override")
12
+ .option("--device-code", "Force device code flow (for remote terminals)")
12
13
  .action(async (options) => {
13
14
  const {
14
15
  startLoginServer,
16
+ startDeviceCodeLogin,
15
17
  getCloudAuth,
16
18
  DEFAULT_CLOUD_URL,
17
19
  } = await import("../../services/cloud.service.ts");
@@ -31,8 +33,25 @@ export function registerCloudCommands(program: Command): void {
31
33
  }
32
34
 
33
35
  try {
34
- const auth = await startLoginServer(cloudUrl);
35
- console.log(` ✓ Logged in as ${auth.email}\n`);
36
+ let auth;
37
+
38
+ // Use device code flow if: forced by flag, running in SSH/PPM terminal, or no display
39
+ const useDeviceCode = options.deviceCode || !process.env.DISPLAY && process.platform === "linux"
40
+ || process.env.PPM_TERMINAL === "1";
41
+
42
+ if (useDeviceCode) {
43
+ auth = await startDeviceCodeLogin(cloudUrl);
44
+ } else {
45
+ // Try browser flow, fall back to device code on failure
46
+ try {
47
+ auth = await startLoginServer(cloudUrl);
48
+ } catch {
49
+ console.log(" Browser login failed, switching to device code flow...\n");
50
+ auth = await startDeviceCodeLogin(cloudUrl);
51
+ }
52
+ }
53
+
54
+ console.log(`\n ✓ Logged in as ${auth.email}\n`);
36
55
  console.log(` Next: run 'ppm cloud link' to register this machine.\n`);
37
56
  } catch (err: unknown) {
38
57
  const msg = err instanceof Error ? err.message : String(err);
@@ -70,7 +89,26 @@ export function registerCloudCommands(program: Command): void {
70
89
  console.log(` ✓ Machine linked`);
71
90
  console.log(` Name: ${device.name}`);
72
91
  console.log(` ID: ${device.device_id}`);
73
- 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();
74
112
  } catch (err: unknown) {
75
113
  const msg = err instanceof Error ? err.message : String(err);
76
114
  console.error(` ✗ Link failed: ${msg}\n`);
@@ -200,6 +200,82 @@ export async function startLoginServer(cloudUrl: string): Promise<CloudAuth> {
200
200
  });
201
201
  }
202
202
 
203
+ /**
204
+ * Device code login flow (RFC 8628).
205
+ * Works from PPM terminal, SSH, or any remote session.
206
+ * User enters a short code on ppm.hienle.tech/verify from any browser.
207
+ */
208
+ export async function startDeviceCodeLogin(cloudUrl: string): Promise<CloudAuth> {
209
+ // 1. Request device code
210
+ const res = await fetch(`${cloudUrl}/auth/device-code`, {
211
+ method: "POST",
212
+ headers: { "Content-Type": "application/json" },
213
+ });
214
+
215
+ if (!res.ok) throw new Error(`Failed to initiate device code: ${res.status}`);
216
+
217
+ const data = await res.json() as {
218
+ device_code: string;
219
+ user_code: string;
220
+ verification_uri: string;
221
+ expires_in: number;
222
+ interval: number;
223
+ };
224
+
225
+ // 2. Display code to user
226
+ console.log(`\n ┌──────────────────────────────────────────┐`);
227
+ console.log(` │ Visit: ${data.verification_uri}`);
228
+ console.log(` │ Enter code: ${data.user_code}`);
229
+ console.log(` └──────────────────────────────────────────┘\n`);
230
+
231
+ // 3. Poll until approved or expired
232
+ const pollInterval = (data.interval || 5) * 1000;
233
+ const deadline = Date.now() + data.expires_in * 1000;
234
+
235
+ while (Date.now() < deadline) {
236
+ await Bun.sleep(pollInterval);
237
+
238
+ try {
239
+ const pollRes = await fetch(`${cloudUrl}/auth/device-code/poll`, {
240
+ method: "POST",
241
+ headers: { "Content-Type": "application/json" },
242
+ body: JSON.stringify({ device_code: data.device_code }),
243
+ });
244
+
245
+ if (!pollRes.ok) {
246
+ if (pollRes.status === 410) throw new Error("Code expired. Try again.");
247
+ continue;
248
+ }
249
+
250
+ const result = await pollRes.json() as {
251
+ status: string;
252
+ access_token?: string;
253
+ email?: string;
254
+ };
255
+
256
+ if (result.status === "approved" && result.access_token && result.email) {
257
+ const auth: CloudAuth = {
258
+ access_token: result.access_token,
259
+ refresh_token: "",
260
+ email: result.email,
261
+ cloud_url: cloudUrl,
262
+ saved_at: new Date().toISOString(),
263
+ };
264
+ saveCloudAuth(auth);
265
+ return auth;
266
+ }
267
+
268
+ // Still pending — show dots
269
+ process.stdout.write(".");
270
+ } catch (err) {
271
+ if (err instanceof Error && err.message.includes("expired")) throw err;
272
+ // Network error — keep polling
273
+ }
274
+ }
275
+
276
+ throw new Error("Login timed out. Try again.");
277
+ }
278
+
203
279
  // ─── Device Registration ────────────────────────────────────────────────
204
280
 
205
281
  /** Register or re-register this machine with cloud */