@hienlh/ppm 0.7.26 → 0.7.28
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 +21 -0
- package/CLAUDE.md +18 -0
- package/dist/web/assets/chat-tab-B0UcLXFA.js +7 -0
- package/dist/web/assets/{code-editor-Gf5j24XD.js → code-editor-Bb-RxKRW.js} +1 -1
- package/dist/web/assets/{database-viewer-CavLUDcg.js → database-viewer-B4pr_bwC.js} +1 -1
- package/dist/web/assets/{diff-viewer-DCtD7LAK.js → diff-viewer-DuHuqbG4.js} +1 -1
- package/dist/web/assets/{git-graph-ihMFS2VR.js → git-graph-BkTGWVMA.js} +1 -1
- package/dist/web/assets/index-BCibi3mV.css +2 -0
- package/dist/web/assets/index-BWVej31S.js +28 -0
- package/dist/web/assets/keybindings-store-DoOYThSa.js +1 -0
- package/dist/web/assets/{markdown-renderer-BQoBZBCF.js → markdown-renderer-CyObkWZ-.js} +1 -1
- package/dist/web/assets/{postgres-viewer-SV4HmMM_.js → postgres-viewer-CHVUVt7c.js} +1 -1
- package/dist/web/assets/{settings-tab-omvX466u.js → settings-tab-C1Uj7t80.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-CZz6cYBU.js → sqlite-viewer-D1ohxjF9.js} +1 -1
- package/dist/web/assets/{switch-PAf5UhcN.js → switch-UODDpwuO.js} +1 -1
- package/dist/web/assets/{terminal-tab-C-fMaKoC.js → terminal-tab-DGzY_K3A.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/docs/design-guidelines.md +79 -0
- package/docs/project-roadmap.md +121 -397
- package/package.json +1 -1
- package/src/cli/commands/restart.ts +59 -10
- package/src/lib/account-crypto.ts +52 -1
- package/src/server/routes/accounts.ts +18 -9
- package/src/services/account.service.ts +46 -9
- package/src/web/components/chat/usage-badge.tsx +1 -1
- package/src/web/components/settings/accounts-settings-section.tsx +237 -165
- package/src/web/lib/api-settings.ts +4 -0
- package/dist/web/assets/chat-tab-RhAZhVvp.js +0 -7
- package/dist/web/assets/index-DVuyQcnI.css +0 -2
- package/dist/web/assets/index-bndwgasB.js +0 -28
- package/dist/web/assets/keybindings-store-C69-mCE5.js +0 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { readFileSync, writeFileSync, existsSync, openSync } from "node:fs";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, openSync, unlinkSync } from "node:fs";
|
|
4
4
|
|
|
5
5
|
const PPM_DIR = resolve(homedir(), ".ppm");
|
|
6
6
|
const STATUS_FILE = resolve(PPM_DIR, "status.json");
|
|
7
7
|
const PID_FILE = resolve(PPM_DIR, "ppm.pid");
|
|
8
8
|
const RESTARTING_FLAG = resolve(PPM_DIR, ".restarting");
|
|
9
|
+
const RESTART_RESULT = resolve(PPM_DIR, ".restart-result");
|
|
9
10
|
|
|
10
11
|
/** Restart only the server process, keeping the tunnel alive */
|
|
11
12
|
export async function restartServer(options: { config?: string }) {
|
|
@@ -42,21 +43,28 @@ export async function restartServer(options: { config?: string }) {
|
|
|
42
43
|
// Write restarting flag so tunnel cleanup handler skips killing cloudflared
|
|
43
44
|
writeFileSync(RESTARTING_FLAG, "");
|
|
44
45
|
|
|
46
|
+
// Clear previous result
|
|
47
|
+
try { unlinkSync(RESTART_RESULT); } catch {}
|
|
48
|
+
|
|
49
|
+
// Pre-restart message — user sees this before terminal dies (if running inside PPM)
|
|
50
|
+
console.log("\n Restarting PPM server...");
|
|
51
|
+
console.log(" If you're using PPM terminal, wait a few seconds then reload the page.\n");
|
|
52
|
+
|
|
45
53
|
// Generate a self-contained restart worker script.
|
|
46
|
-
//
|
|
47
|
-
// (and the PPM server hosting its terminal) is killed.
|
|
54
|
+
// Runs as a detached process so it survives when the PPM server (and its terminals) die.
|
|
48
55
|
const params = JSON.stringify({
|
|
49
56
|
serverPid, port, host, serverScript,
|
|
50
57
|
config: options.config ?? "",
|
|
51
58
|
statusFile: STATUS_FILE,
|
|
52
59
|
pidFile: PID_FILE,
|
|
53
60
|
restartingFlag: RESTARTING_FLAG,
|
|
61
|
+
resultFile: RESTART_RESULT,
|
|
54
62
|
ppmDir: PPM_DIR,
|
|
55
63
|
});
|
|
56
64
|
|
|
57
65
|
const workerPath = resolve(PPM_DIR, ".restart-worker.ts");
|
|
58
66
|
writeFileSync(workerPath, `
|
|
59
|
-
import { readFileSync, writeFileSync, openSync, unlinkSync, appendFileSync
|
|
67
|
+
import { readFileSync, writeFileSync, openSync, unlinkSync, appendFileSync } from "node:fs";
|
|
60
68
|
import { createServer } from "node:net";
|
|
61
69
|
|
|
62
70
|
const P = ${params};
|
|
@@ -66,6 +74,9 @@ async function main() {
|
|
|
66
74
|
const ts = new Date().toISOString();
|
|
67
75
|
try { appendFileSync(P.ppmDir + "/ppm.log", "[" + ts + "] [" + level + "] " + msg + "\\n"); } catch {}
|
|
68
76
|
};
|
|
77
|
+
const writeResult = (ok: boolean, msg: string) => {
|
|
78
|
+
try { writeFileSync(P.resultFile, JSON.stringify({ ok, message: msg })); } catch {}
|
|
79
|
+
};
|
|
69
80
|
|
|
70
81
|
// Kill old server
|
|
71
82
|
try { process.kill(P.serverPid); log("INFO", "Restart: killed old server PID " + P.serverPid); } catch {}
|
|
@@ -115,12 +126,32 @@ async function main() {
|
|
|
115
126
|
await Bun.sleep(300);
|
|
116
127
|
}
|
|
117
128
|
|
|
129
|
+
// Check tunnel
|
|
130
|
+
let tunnelAlive = false;
|
|
131
|
+
let tunnelPid: number | undefined;
|
|
132
|
+
let shareUrl: string | undefined;
|
|
133
|
+
try {
|
|
134
|
+
const st = JSON.parse(readFileSync(P.statusFile, "utf-8"));
|
|
135
|
+
tunnelPid = st.tunnelPid;
|
|
136
|
+
shareUrl = st.shareUrl;
|
|
137
|
+
if (tunnelPid) { process.kill(tunnelPid, 0); tunnelAlive = true; }
|
|
138
|
+
} catch {}
|
|
139
|
+
|
|
118
140
|
if (ready) {
|
|
119
|
-
|
|
141
|
+
let msg = "Restart complete (PID: " + child.pid + ", port: " + P.port + ")";
|
|
142
|
+
if (shareUrl && tunnelPid) {
|
|
143
|
+
msg += tunnelAlive ? " — tunnel alive" : " — tunnel dead, run 'ppm stop && ppm start --share'";
|
|
144
|
+
}
|
|
145
|
+
log("INFO", msg);
|
|
146
|
+
writeResult(true, msg);
|
|
120
147
|
} else {
|
|
121
148
|
let alive = false;
|
|
122
149
|
try { process.kill(child.pid, 0); alive = true; } catch {}
|
|
123
|
-
|
|
150
|
+
const msg = alive
|
|
151
|
+
? "Server started but not responding on port " + P.port + ". Check: ppm logs"
|
|
152
|
+
: "Server crashed on startup. Check: ppm logs";
|
|
153
|
+
log("ERROR", msg);
|
|
154
|
+
writeResult(false, msg);
|
|
124
155
|
}
|
|
125
156
|
|
|
126
157
|
// Cleanup worker file
|
|
@@ -140,9 +171,27 @@ main();
|
|
|
140
171
|
});
|
|
141
172
|
worker.unref();
|
|
142
173
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
174
|
+
// Poll for result — if running from external terminal, we'll see the result.
|
|
175
|
+
// If running from PPM terminal, this process dies when server is killed — that's fine,
|
|
176
|
+
// the user already saw the pre-restart message above.
|
|
177
|
+
const pollStart = Date.now();
|
|
178
|
+
while (Date.now() - pollStart < 20000) {
|
|
179
|
+
await Bun.sleep(500);
|
|
180
|
+
if (existsSync(RESTART_RESULT)) {
|
|
181
|
+
try {
|
|
182
|
+
const result = JSON.parse(readFileSync(RESTART_RESULT, "utf-8"));
|
|
183
|
+
if (result.ok) {
|
|
184
|
+
console.log(` ✓ ${result.message}`);
|
|
185
|
+
} else {
|
|
186
|
+
console.error(` ✗ ${result.message}`);
|
|
187
|
+
}
|
|
188
|
+
unlinkSync(RESTART_RESULT);
|
|
189
|
+
} catch {}
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
146
193
|
|
|
147
|
-
|
|
194
|
+
// Timeout — worker might still be running
|
|
195
|
+
console.error(" ⚠ Restart timed out. Check: ppm logs");
|
|
196
|
+
process.exit(1);
|
|
148
197
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
@@ -39,6 +39,57 @@ export function encrypt(plaintext: string): string {
|
|
|
39
39
|
return `${iv.toString("hex")}:${tag.toString("hex")}:${enc.toString("hex")}`;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Password-based encryption for portable export (cross-machine)
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
interface EncryptedExport {
|
|
47
|
+
version: 1;
|
|
48
|
+
kdf: "scrypt";
|
|
49
|
+
salt: string; // hex, 32 bytes
|
|
50
|
+
iv: string; // hex, 12 bytes
|
|
51
|
+
authTag: string; // hex, 16 bytes
|
|
52
|
+
ciphertext: string; // base64
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Encrypt payload with user password → portable encrypted JSON blob */
|
|
56
|
+
export function encryptWithPassword(plaintext: string, password: string): string {
|
|
57
|
+
const salt = randomBytes(32);
|
|
58
|
+
const iv = randomBytes(12);
|
|
59
|
+
// scrypt N=16384 (r=8, p=1) → 16MB mem, ~50ms — secure & within Node/Bun default limits
|
|
60
|
+
const key = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
|
|
61
|
+
const cipher = createCipheriv(ALGO, key, iv);
|
|
62
|
+
const enc = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
|
|
63
|
+
const envelope: EncryptedExport = {
|
|
64
|
+
version: 1,
|
|
65
|
+
kdf: "scrypt",
|
|
66
|
+
salt: salt.toString("hex"),
|
|
67
|
+
iv: iv.toString("hex"),
|
|
68
|
+
authTag: cipher.getAuthTag().toString("hex"),
|
|
69
|
+
ciphertext: enc.toString("base64"),
|
|
70
|
+
};
|
|
71
|
+
return JSON.stringify(envelope);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Decrypt portable encrypted JSON blob with user password → plaintext */
|
|
75
|
+
export function decryptWithPassword(blob: string, password: string): string {
|
|
76
|
+
let envelope: EncryptedExport;
|
|
77
|
+
try { envelope = JSON.parse(blob); } catch { throw new Error("Invalid backup format"); }
|
|
78
|
+
if (envelope.version !== 1 || envelope.kdf !== "scrypt") throw new Error("Unsupported backup version");
|
|
79
|
+
const salt = Buffer.from(envelope.salt, "hex");
|
|
80
|
+
const iv = Buffer.from(envelope.iv, "hex");
|
|
81
|
+
const authTag = Buffer.from(envelope.authTag, "hex");
|
|
82
|
+
const ciphertext = Buffer.from(envelope.ciphertext, "base64");
|
|
83
|
+
const key = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
|
|
84
|
+
const decipher = createDecipheriv(ALGO, key, iv);
|
|
85
|
+
decipher.setAuthTag(authTag);
|
|
86
|
+
try {
|
|
87
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf-8");
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error("Wrong password or corrupted backup");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
42
93
|
/** Decrypt "iv:authTag:ciphertext" → plaintext */
|
|
43
94
|
export function decrypt(encoded: string): string {
|
|
44
95
|
const parts = encoded.split(":");
|
|
@@ -153,19 +153,28 @@ accountsRoutes.post("/oauth/refresh/:id", async (c) => {
|
|
|
153
153
|
}
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
-
/**
|
|
157
|
-
accountsRoutes.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
156
|
+
/** POST /api/accounts/export — download password-encrypted accounts backup */
|
|
157
|
+
accountsRoutes.post("/export", async (c) => {
|
|
158
|
+
try {
|
|
159
|
+
const { password, accountIds } = await c.req.json() as { password: string; accountIds?: string[] };
|
|
160
|
+
if (!password) return c.json(err("Password required"), 400);
|
|
161
|
+
await accountService.refreshBeforeExport(accountIds);
|
|
162
|
+
const blob = accountService.exportEncrypted(password, accountIds);
|
|
163
|
+
c.header("Content-Disposition", "attachment; filename=ppm-accounts-backup.json");
|
|
164
|
+
c.header("Content-Type", "application/json");
|
|
165
|
+
return c.body(blob);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return c.json(err((e as Error).message), 400);
|
|
168
|
+
}
|
|
162
169
|
});
|
|
163
170
|
|
|
164
|
-
/** POST /api/accounts/import — restore accounts from backup */
|
|
171
|
+
/** POST /api/accounts/import — restore accounts from password-encrypted backup */
|
|
165
172
|
accountsRoutes.post("/import", async (c) => {
|
|
166
173
|
try {
|
|
167
|
-
const
|
|
168
|
-
|
|
174
|
+
const { data, password } = await c.req.json() as { data: string; password: string };
|
|
175
|
+
if (!data) return c.json(err("Backup data required"), 400);
|
|
176
|
+
if (!password) return c.json(err("Password required"), 400);
|
|
177
|
+
const count = accountService.importEncrypted(data, password);
|
|
169
178
|
return c.json(ok({ imported: count }));
|
|
170
179
|
} catch (e) {
|
|
171
180
|
return c.json(err((e as Error).message), 400);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID, createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { encrypt, decrypt } from "../lib/account-crypto.ts";
|
|
2
|
+
import { encrypt, decrypt, encryptWithPassword, decryptWithPassword } from "../lib/account-crypto.ts";
|
|
3
3
|
import {
|
|
4
4
|
getAccounts,
|
|
5
5
|
getAccountById,
|
|
@@ -57,6 +57,12 @@ export interface OAuthProfileData {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** Check if a token string looks like our encrypted format "iv:authTag:ciphertext" (all hex) */
|
|
61
|
+
function looksEncrypted(value: string): boolean {
|
|
62
|
+
const parts = value.split(":");
|
|
63
|
+
return parts.length === 3 && parts.every((p) => /^[0-9a-f]+$/i.test(p));
|
|
64
|
+
}
|
|
65
|
+
|
|
60
66
|
const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
61
67
|
const OAUTH_AUTH_URL = "https://claude.ai/oauth/authorize";
|
|
62
68
|
const OAUTH_TOKEN_URL = "https://api.anthropic.com/v1/oauth/token";
|
|
@@ -506,14 +512,40 @@ class AccountService {
|
|
|
506
512
|
// Export / Import encrypted backup
|
|
507
513
|
// ---------------------------------------------------------------------------
|
|
508
514
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
|
|
515
|
+
/** Refresh all OAuth tokens before export so they're fresh on the target machine */
|
|
516
|
+
async refreshBeforeExport(accountIds?: string[]): Promise<void> {
|
|
517
|
+
const accounts = accountIds?.length
|
|
518
|
+
? accountIds.map((id) => this.getWithTokens(id)).filter(Boolean) as AccountWithTokens[]
|
|
519
|
+
: this.list().map((a) => this.getWithTokens(a.id)).filter(Boolean) as AccountWithTokens[];
|
|
520
|
+
for (const acc of accounts) {
|
|
521
|
+
if (!acc.accessToken.startsWith("sk-ant-oat")) continue; // only OAuth tokens
|
|
522
|
+
if (!acc.expiresAt) continue;
|
|
523
|
+
try {
|
|
524
|
+
await this.refreshAccessToken(acc.id);
|
|
525
|
+
} catch {
|
|
526
|
+
// Best-effort — skip accounts whose refresh token is already invalid
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
exportEncrypted(password: string, accountIds?: string[]): string {
|
|
532
|
+
// Fetch requested accounts (or all), decrypt tokens, encrypt whole payload with user password
|
|
533
|
+
const rows = accountIds?.length
|
|
534
|
+
? accountIds.map((id) => getAccountById(id)).filter(Boolean) as AccountRow[]
|
|
535
|
+
: getAccounts();
|
|
536
|
+
const portable = rows.map((row) => {
|
|
537
|
+
let accessToken = row.access_token;
|
|
538
|
+
let refreshToken = row.refresh_token;
|
|
539
|
+
try { accessToken = decrypt(accessToken); } catch { /* already plaintext or corrupt */ }
|
|
540
|
+
try { refreshToken = decrypt(refreshToken); } catch { /* already plaintext or corrupt */ }
|
|
541
|
+
return { ...row, access_token: accessToken, refresh_token: refreshToken };
|
|
542
|
+
});
|
|
543
|
+
return encryptWithPassword(JSON.stringify(portable), password);
|
|
513
544
|
}
|
|
514
545
|
|
|
515
|
-
importEncrypted(
|
|
516
|
-
const
|
|
546
|
+
importEncrypted(blob: string, password: string): number {
|
|
547
|
+
const plaintext = decryptWithPassword(blob, password);
|
|
548
|
+
const rows = JSON.parse(plaintext) as AccountRow[];
|
|
517
549
|
if (!Array.isArray(rows)) throw new Error("Invalid backup format");
|
|
518
550
|
let count = 0;
|
|
519
551
|
for (const row of rows) {
|
|
@@ -521,12 +553,17 @@ class AccountService {
|
|
|
521
553
|
// Skip if account already exists (by id or email)
|
|
522
554
|
if (getAccountById(row.id)) continue;
|
|
523
555
|
if (row.email && this.list().some((a) => a.email === row.email)) continue;
|
|
556
|
+
// Re-encrypt with this machine's key
|
|
557
|
+
let accessToken = row.access_token;
|
|
558
|
+
let refreshToken = row.refresh_token;
|
|
559
|
+
if (!looksEncrypted(accessToken)) accessToken = encrypt(accessToken);
|
|
560
|
+
if (!looksEncrypted(refreshToken)) refreshToken = encrypt(refreshToken);
|
|
524
561
|
insertAccount({
|
|
525
562
|
id: row.id,
|
|
526
563
|
label: row.label,
|
|
527
564
|
email: row.email,
|
|
528
|
-
access_token:
|
|
529
|
-
refresh_token:
|
|
565
|
+
access_token: accessToken,
|
|
566
|
+
refresh_token: refreshToken,
|
|
530
567
|
expires_at: row.expires_at,
|
|
531
568
|
status: row.status ?? "active",
|
|
532
569
|
cooldown_until: row.cooldown_until,
|
|
@@ -295,7 +295,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
|
|
|
295
295
|
<div className="flex items-center gap-1">
|
|
296
296
|
{onReload && (
|
|
297
297
|
<button
|
|
298
|
-
onClick={() => {
|
|
298
|
+
onClick={() => { onReload(); loadAll(); }}
|
|
299
299
|
disabled={loading || refreshing}
|
|
300
300
|
className="text-xs text-text-subtle hover:text-text-primary px-1 disabled:opacity-50 cursor-pointer"
|
|
301
301
|
title="Refresh"
|