@hienlh/ppm 0.5.20 → 0.6.0
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 +6 -0
- package/bun.lock +45 -0
- package/dist/web/assets/{api-client-BxCvlogn.js → api-client-ANLU-Irq.js} +1 -1
- package/dist/web/assets/chat-tab-C24nbKz1.js +7 -0
- package/dist/web/assets/{code-editor-0YVgeS1c.js → code-editor-DjIL6ta3.js} +1 -1
- package/dist/web/assets/{diff-viewer-CtEmKn4e.js → diff-viewer-BnvcXY3g.js} +1 -1
- package/dist/web/assets/{git-graph-DycoowxO.js → git-graph-iAf_zaqe.js} +1 -1
- package/dist/web/assets/index-BwLVvoev.js +21 -0
- package/dist/web/assets/index-CP_2zE5O.css +2 -0
- package/dist/web/assets/{input-Bzyi1GeB.js → input-DV4tynJq.js} +1 -1
- package/dist/web/assets/{jsx-runtime-Bzk8w7Zh.js → jsx-runtime-B4BJKQ1u.js} +1 -1
- package/dist/web/assets/{markdown-renderer-LHjvxp5Q.js → markdown-renderer-CIfiE3o8.js} +2 -2
- package/dist/web/assets/react-WvgCEYPV.js +1 -0
- package/dist/web/assets/{rotate-ccw-ZqeedZLA.js → rotate-ccw-BesidNnx.js} +1 -1
- package/dist/web/assets/settings-store-BGF8--S9.js +1 -0
- package/dist/web/assets/settings-tab-B_QwULcp.js +1 -0
- package/dist/web/assets/sqlite-viewer-DpGb3i2g.js +16 -0
- package/dist/web/assets/tab-store-L0a7ao4c.js +1 -0
- package/dist/web/assets/{terminal-tab-B2QEABNU.js → terminal-tab-4-DINw_B.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-BFv4d2_j.js → use-monaco-theme-RFoGvnp0.js} +2 -2
- package/dist/web/index.html +9 -8
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +96 -61
- package/docs/deployment-guide.md +16 -14
- package/docs/design-guidelines.md +5 -2
- package/docs/project-overview-pdr.md +20 -17
- package/docs/project-roadmap.md +35 -23
- package/docs/system-architecture.md +27 -18
- package/package.json +4 -1
- package/src/cli/commands/init.ts +7 -2
- package/src/cli/commands/restart.ts +6 -0
- package/src/index.ts +9 -1
- package/src/providers/claude-agent-sdk.ts +59 -28
- package/src/server/index.ts +10 -2
- package/src/server/routes/chat.ts +19 -0
- package/src/server/routes/project-scoped.ts +2 -0
- package/src/server/routes/sqlite.ts +75 -0
- package/src/server/routes/tunnel.ts +17 -2
- package/src/server/ws/chat.ts +33 -1
- package/src/services/config.service.ts +182 -58
- package/src/services/db.service.ts +303 -0
- package/src/services/push-notification.service.ts +23 -37
- package/src/services/session-log.service.ts +12 -24
- package/src/services/sqlite.service.ts +144 -0
- package/src/web/components/chat/chat-history-bar.tsx +68 -8
- package/src/web/components/chat/chat-tab.tsx +10 -1
- package/src/web/components/chat/file-picker.tsx +1 -1
- package/src/web/components/chat/slash-command-picker.tsx +1 -1
- package/src/web/components/explorer/file-tree.tsx +3 -1
- package/src/web/components/layout/draggable-tab.tsx +50 -4
- package/src/web/components/layout/editor-panel.tsx +1 -0
- package/src/web/components/layout/mobile-nav.tsx +2 -2
- package/src/web/components/layout/project-bar.tsx +40 -17
- package/src/web/components/layout/tab-bar.tsx +16 -1
- package/src/web/components/layout/tab-content.tsx +5 -0
- package/src/web/components/sqlite/sqlite-data-grid.tsx +165 -0
- package/src/web/components/sqlite/sqlite-query-editor.tsx +97 -0
- package/src/web/components/sqlite/sqlite-table-list.tsx +48 -0
- package/src/web/components/sqlite/sqlite-viewer.tsx +117 -0
- package/src/web/components/sqlite/use-sqlite.ts +97 -0
- package/src/web/hooks/use-chat.ts +12 -0
- package/src/web/stores/tab-store.ts +1 -0
- package/dist/web/assets/chat-tab-D_LO6cRM.js +0 -7
- package/dist/web/assets/index-82E_pIrH.css +0 -2
- package/dist/web/assets/index-y49eIXuR.js +0 -21
- package/dist/web/assets/settings-store-DikslxSJ.js +0 -1
- package/dist/web/assets/settings-tab-Dt9Sv1zx.js +0 -1
- package/dist/web/assets/tab-store-BNgVKR5w.js +0 -1
- /package/dist/web/assets/{utils-EM9hC5pN.js → utils-C2KxHr1H.js} +0 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { sqliteService } from "../../services/sqlite.service.ts";
|
|
3
|
+
import { ok, err } from "../../types/api.ts";
|
|
4
|
+
|
|
5
|
+
type Env = { Variables: { projectPath: string; projectName: string } };
|
|
6
|
+
|
|
7
|
+
export const sqliteRoutes = new Hono<Env>();
|
|
8
|
+
|
|
9
|
+
/** GET /sqlite/tables?path=relative/path.db */
|
|
10
|
+
sqliteRoutes.get("/tables", (c) => {
|
|
11
|
+
try {
|
|
12
|
+
const dbPath = c.req.query("path");
|
|
13
|
+
if (!dbPath) return c.json(err("Missing query parameter: path"), 400);
|
|
14
|
+
const tables = sqliteService.getTables(c.get("projectPath"), dbPath);
|
|
15
|
+
return c.json(ok(tables));
|
|
16
|
+
} catch (e) {
|
|
17
|
+
return c.json(err((e as Error).message), 500);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** GET /sqlite/schema?path=...&table=... */
|
|
22
|
+
sqliteRoutes.get("/schema", (c) => {
|
|
23
|
+
try {
|
|
24
|
+
const dbPath = c.req.query("path");
|
|
25
|
+
const table = c.req.query("table");
|
|
26
|
+
if (!dbPath || !table) return c.json(err("Missing query parameters: path, table"), 400);
|
|
27
|
+
const schema = sqliteService.getTableSchema(c.get("projectPath"), dbPath, table);
|
|
28
|
+
return c.json(ok(schema));
|
|
29
|
+
} catch (e) {
|
|
30
|
+
return c.json(err((e as Error).message), 500);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** GET /sqlite/data?path=...&table=...&page=1&limit=100&orderBy=...&orderDir=ASC */
|
|
35
|
+
sqliteRoutes.get("/data", (c) => {
|
|
36
|
+
try {
|
|
37
|
+
const dbPath = c.req.query("path");
|
|
38
|
+
const table = c.req.query("table");
|
|
39
|
+
if (!dbPath || !table) return c.json(err("Missing query parameters: path, table"), 400);
|
|
40
|
+
const page = parseInt(c.req.query("page") ?? "1", 10);
|
|
41
|
+
const limit = Math.min(parseInt(c.req.query("limit") ?? "100", 10), 1000);
|
|
42
|
+
const orderBy = c.req.query("orderBy");
|
|
43
|
+
const orderDir = c.req.query("orderDir") === "DESC" ? "DESC" : "ASC";
|
|
44
|
+
const data = sqliteService.getTableData(c.get("projectPath"), dbPath, table, page, limit, orderBy, orderDir as "ASC" | "DESC");
|
|
45
|
+
return c.json(ok(data));
|
|
46
|
+
} catch (e) {
|
|
47
|
+
return c.json(err((e as Error).message), 500);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** POST /sqlite/query — body: { path, sql } */
|
|
52
|
+
sqliteRoutes.post("/query", async (c) => {
|
|
53
|
+
try {
|
|
54
|
+
const body = await c.req.json<{ path: string; sql: string }>();
|
|
55
|
+
if (!body.path || !body.sql) return c.json(err("Missing required fields: path, sql"), 400);
|
|
56
|
+
const result = sqliteService.executeQuery(c.get("projectPath"), body.path, body.sql);
|
|
57
|
+
return c.json(ok(result));
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return c.json(err((e as Error).message), 500);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** PUT /sqlite/cell — body: { path, table, rowid, column, value } */
|
|
64
|
+
sqliteRoutes.put("/cell", async (c) => {
|
|
65
|
+
try {
|
|
66
|
+
const body = await c.req.json<{ path: string; table: string; rowid: number; column: string; value: unknown }>();
|
|
67
|
+
if (!body.path || !body.table || body.rowid == null || !body.column) {
|
|
68
|
+
return c.json(err("Missing required fields: path, table, rowid, column"), 400);
|
|
69
|
+
}
|
|
70
|
+
sqliteService.updateCell(c.get("projectPath"), body.path, body.table, body.rowid, body.column, body.value);
|
|
71
|
+
return c.json(ok({ updated: true }));
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return c.json(err((e as Error).message), 500);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
+
import { networkInterfaces } from "node:os";
|
|
2
3
|
import { tunnelService } from "../../services/tunnel.service.ts";
|
|
3
4
|
import { configService } from "../../services/config.service.ts";
|
|
4
5
|
import { ok, err } from "../../types/api.ts";
|
|
5
6
|
|
|
7
|
+
/** Return first non-internal IPv4 address */
|
|
8
|
+
function getLocalIp(): string | null {
|
|
9
|
+
const nets = networkInterfaces();
|
|
10
|
+
for (const name of Object.keys(nets)) {
|
|
11
|
+
for (const net of nets[name] ?? []) {
|
|
12
|
+
if (net.family === "IPv4" && !net.internal) return net.address;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
export const tunnelRoutes = new Hono();
|
|
7
19
|
|
|
8
|
-
/** GET /api/tunnel — current tunnel status */
|
|
20
|
+
/** GET /api/tunnel — current tunnel status + local URL */
|
|
9
21
|
tunnelRoutes.get("/", (c) => {
|
|
10
22
|
const url = tunnelService.getTunnelUrl();
|
|
11
|
-
|
|
23
|
+
const port = configService.get("port") ?? 8080;
|
|
24
|
+
const localIp = getLocalIp();
|
|
25
|
+
const localUrl = localIp ? `http://${localIp}:${port}` : null;
|
|
26
|
+
return c.json(ok({ active: !!url, url, localUrl }));
|
|
12
27
|
});
|
|
13
28
|
|
|
14
29
|
/** POST /api/tunnel/start — start tunnel if not already running */
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { chatService } from "../../services/chat.service.ts";
|
|
|
2
2
|
import { providerRegistry } from "../../providers/registry.ts";
|
|
3
3
|
import { resolveProjectPath } from "../helpers/resolve-project.ts";
|
|
4
4
|
import { logSessionEvent } from "../../services/session-log.service.ts";
|
|
5
|
+
import { listSessions as sdkListSessions } from "@anthropic-ai/claude-agent-sdk";
|
|
5
6
|
import type { ChatWsClientMessage } from "../../types/api.ts";
|
|
6
7
|
|
|
7
8
|
const PING_INTERVAL_MS = 15_000; // 15s keepalive
|
|
@@ -148,6 +149,16 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
|
|
|
148
149
|
} else if (evType === "done") {
|
|
149
150
|
logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
|
|
150
151
|
if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
|
|
152
|
+
// Fire-and-forget: fetch updated session title from SDK summary
|
|
153
|
+
sdkListSessions({ dir: entry.projectPath, limit: 50 }).then((sessions) => {
|
|
154
|
+
const found = sessions.find((s) => s.sessionId === sessionId || s.sessionId === ev.sessionId);
|
|
155
|
+
if (found?.summary) {
|
|
156
|
+
safeSend(sessionId, { type: "title_updated", title: found.summary });
|
|
157
|
+
// Also update in-memory session title
|
|
158
|
+
const session = chatService.getSession(sessionId);
|
|
159
|
+
if (session) session.title = found.summary;
|
|
160
|
+
}
|
|
161
|
+
}).catch(() => {});
|
|
151
162
|
// Fire-and-forget push notification
|
|
152
163
|
import("../../services/push-notification.service.ts").then(({ pushService }) => {
|
|
153
164
|
const project = entry.projectName || "Project";
|
|
@@ -254,7 +265,18 @@ export const chatWebSocket = {
|
|
|
254
265
|
sessionId,
|
|
255
266
|
isStreaming: existing.isStreaming,
|
|
256
267
|
pendingApproval: existing.pendingApprovalEvent ?? null,
|
|
268
|
+
sessionTitle: session?.title || null,
|
|
257
269
|
}));
|
|
270
|
+
// Async: resolve title from SDK if in-memory title is generic
|
|
271
|
+
if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
|
|
272
|
+
sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
|
|
273
|
+
const found = sessions.find((s) => s.sessionId === sessionId);
|
|
274
|
+
if (found?.summary) {
|
|
275
|
+
safeSend(sessionId, { type: "title_updated", title: found.summary });
|
|
276
|
+
if (session) session.title = found.summary;
|
|
277
|
+
}
|
|
278
|
+
}).catch(() => {});
|
|
279
|
+
}
|
|
258
280
|
console.log(`[chat] session=${sessionId} FE reconnected (streaming=${existing.isStreaming}, catchUp=${existing.needsCatchUp})`);
|
|
259
281
|
return;
|
|
260
282
|
}
|
|
@@ -276,7 +298,17 @@ export const chatWebSocket = {
|
|
|
276
298
|
needsCatchUp: false,
|
|
277
299
|
catchUpText: "",
|
|
278
300
|
});
|
|
279
|
-
ws.send(JSON.stringify({ type: "connected", sessionId }));
|
|
301
|
+
ws.send(JSON.stringify({ type: "connected", sessionId, sessionTitle: session?.title || null }));
|
|
302
|
+
// Async: resolve title from SDK if in-memory title is generic
|
|
303
|
+
if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
|
|
304
|
+
sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
|
|
305
|
+
const found = sessions.find((s) => s.sessionId === sessionId);
|
|
306
|
+
if (found?.summary) {
|
|
307
|
+
safeSend(sessionId, { type: "title_updated", title: found.summary });
|
|
308
|
+
if (session) session.title = found.summary;
|
|
309
|
+
}
|
|
310
|
+
}).catch(() => {});
|
|
311
|
+
}
|
|
280
312
|
},
|
|
281
313
|
|
|
282
314
|
async message(ws: ChatWsSocket, msg: string | ArrayBuffer | Uint8Array) {
|
|
@@ -1,68 +1,78 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { resolve
|
|
1
|
+
import { existsSync, readFileSync, renameSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
|
-
import
|
|
6
|
-
import type { PpmConfig } from "../types/config.ts";
|
|
5
|
+
import type { PpmConfig, ProjectConfig } from "../types/config.ts";
|
|
7
6
|
import { DEFAULT_CONFIG, sanitizeConfig } from "../types/config.ts";
|
|
7
|
+
import {
|
|
8
|
+
getConfigValue,
|
|
9
|
+
setConfigValue,
|
|
10
|
+
getAllConfig,
|
|
11
|
+
getProjects,
|
|
12
|
+
upsertProject,
|
|
13
|
+
deleteProject as dbDeleteProject,
|
|
14
|
+
getDb,
|
|
15
|
+
getDbFilePath,
|
|
16
|
+
} from "./db.service.ts";
|
|
8
17
|
|
|
9
18
|
const PPM_DIR = resolve(homedir(), ".ppm");
|
|
10
|
-
|
|
11
|
-
|
|
19
|
+
|
|
20
|
+
/** Top-level config keys stored in the config table (not projects) */
|
|
21
|
+
const CONFIG_TABLE_KEYS: (keyof PpmConfig)[] = [
|
|
22
|
+
"device_name", "port", "host", "theme", "auth", "ai", "push",
|
|
23
|
+
];
|
|
12
24
|
|
|
13
25
|
class ConfigService {
|
|
14
26
|
private config: PpmConfig = structuredClone(DEFAULT_CONFIG);
|
|
15
|
-
private configPath: string = GLOBAL_CONFIG_PATH;
|
|
16
27
|
|
|
17
|
-
/** Load config from
|
|
28
|
+
/** Load config from DB. If explicitPath given, import that YAML first. */
|
|
18
29
|
load(explicitPath?: string): PpmConfig {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
} catch (err) {
|
|
51
|
-
console.error(`[config] Error reading ${p}:`, (err as Error).message);
|
|
52
|
-
}
|
|
30
|
+
// Import explicit YAML if provided (e.g. `ppm start -c path`)
|
|
31
|
+
if (explicitPath && existsSync(explicitPath)) {
|
|
32
|
+
this.importFromYaml(explicitPath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Auto-migrate: if config.yaml exists but DB has no config rows
|
|
36
|
+
// Skip migration when using in-memory DB (tests)
|
|
37
|
+
if (!getDbFilePath().includes(":memory:")) {
|
|
38
|
+
this.migrateYamlIfNeeded();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Load from DB
|
|
42
|
+
const dbConfig = getAllConfig();
|
|
43
|
+
const dbProjects = getProjects();
|
|
44
|
+
|
|
45
|
+
if (Object.keys(dbConfig).length > 0 || dbProjects.length > 0) {
|
|
46
|
+
this.config = this.assembleConfig(dbConfig, dbProjects);
|
|
47
|
+
console.log("[config] Loaded from SQLite");
|
|
48
|
+
} else {
|
|
49
|
+
console.log("[config] No config found, creating defaults");
|
|
50
|
+
this.config = this.createDefault();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Auto-generate token if auth enabled but empty
|
|
54
|
+
if (this.config.auth.enabled && !this.config.auth.token) {
|
|
55
|
+
this.config.auth.token = randomBytes(16).toString("hex");
|
|
56
|
+
this.save();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (sanitizeConfig(this.config)) {
|
|
60
|
+
this.save();
|
|
53
61
|
}
|
|
54
62
|
|
|
55
|
-
// No config found — create default
|
|
56
|
-
console.log(`[config] No config found, creating default at ${GLOBAL_CONFIG_PATH}`);
|
|
57
|
-
this.config = this.createDefault();
|
|
58
63
|
return this.config;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
/** Save current config to
|
|
66
|
+
/** Save current config to DB */
|
|
62
67
|
save(): void {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
for (const key of CONFIG_TABLE_KEYS) {
|
|
69
|
+
const value = this.config[key];
|
|
70
|
+
if (value !== undefined) {
|
|
71
|
+
setConfigValue(String(key), JSON.stringify(value));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Sync projects to DB
|
|
75
|
+
this.syncProjectsToDb(this.config.projects);
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
/** Get a top-level config key */
|
|
@@ -70,9 +80,14 @@ class ConfigService {
|
|
|
70
80
|
return this.config[key];
|
|
71
81
|
}
|
|
72
82
|
|
|
73
|
-
/** Set a top-level config key */
|
|
83
|
+
/** Set a top-level config key (persists immediately) */
|
|
74
84
|
set<K extends keyof PpmConfig>(key: K, value: PpmConfig[K]): void {
|
|
75
85
|
this.config[key] = value;
|
|
86
|
+
if (key === "projects") {
|
|
87
|
+
this.syncProjectsToDb(value as ProjectConfig[]);
|
|
88
|
+
} else {
|
|
89
|
+
setConfigValue(String(key), JSON.stringify(value));
|
|
90
|
+
}
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
/** Get the full config object */
|
|
@@ -80,24 +95,133 @@ class ConfigService {
|
|
|
80
95
|
return this.config;
|
|
81
96
|
}
|
|
82
97
|
|
|
83
|
-
/** Get the path
|
|
98
|
+
/** Get the DB file path (replaces getConfigPath for YAML) */
|
|
84
99
|
getConfigPath(): string {
|
|
85
|
-
return
|
|
100
|
+
return getDbFilePath();
|
|
86
101
|
}
|
|
87
102
|
|
|
88
|
-
/**
|
|
89
|
-
setConfigPath(
|
|
90
|
-
|
|
91
|
-
|
|
103
|
+
/** No-op — kept for backward compatibility (init command) */
|
|
104
|
+
setConfigPath(_p: string): void {}
|
|
105
|
+
|
|
106
|
+
// ── Private helpers ──────────────────────────────────────────────────
|
|
92
107
|
|
|
93
|
-
/** Create default config with auto-generated auth token */
|
|
94
108
|
private createDefault(): PpmConfig {
|
|
95
109
|
const config = structuredClone(DEFAULT_CONFIG);
|
|
96
110
|
config.auth.token = randomBytes(16).toString("hex");
|
|
97
|
-
this.
|
|
111
|
+
this.config = config;
|
|
98
112
|
this.save();
|
|
99
113
|
return config;
|
|
100
114
|
}
|
|
115
|
+
|
|
116
|
+
private assembleConfig(
|
|
117
|
+
dbRows: Record<string, string>,
|
|
118
|
+
dbProjects: { path: string; name: string; color: string | null }[],
|
|
119
|
+
): PpmConfig {
|
|
120
|
+
const config = structuredClone(DEFAULT_CONFIG);
|
|
121
|
+
for (const [key, jsonValue] of Object.entries(dbRows)) {
|
|
122
|
+
if (key in config && key !== "projects") {
|
|
123
|
+
try {
|
|
124
|
+
(config as any)[key] = JSON.parse(jsonValue);
|
|
125
|
+
} catch { /* keep default */ }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Projects from dedicated table
|
|
129
|
+
config.projects = dbProjects.map((p) => ({
|
|
130
|
+
path: p.path,
|
|
131
|
+
name: p.name,
|
|
132
|
+
...(p.color ? { color: p.color } : {}),
|
|
133
|
+
}));
|
|
134
|
+
return config;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private syncProjectsToDb(projects: ProjectConfig[]): void {
|
|
138
|
+
const db = getDb();
|
|
139
|
+
db.exec("DELETE FROM projects");
|
|
140
|
+
const stmt = db.query(
|
|
141
|
+
"INSERT INTO projects (path, name, color, sort_order) VALUES (?, ?, ?, ?)",
|
|
142
|
+
);
|
|
143
|
+
for (let i = 0; i < projects.length; i++) {
|
|
144
|
+
const p = projects[i]!;
|
|
145
|
+
stmt.run(p.path, p.name, p.color ?? null, i);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private migrateYamlIfNeeded(): void {
|
|
150
|
+
const yamlPaths = [
|
|
151
|
+
resolve(PPM_DIR, "config.yaml"),
|
|
152
|
+
resolve(PPM_DIR, "config.dev.yaml"),
|
|
153
|
+
];
|
|
154
|
+
for (const yamlPath of yamlPaths) {
|
|
155
|
+
if (!existsSync(yamlPath)) continue;
|
|
156
|
+
const existing = getAllConfig();
|
|
157
|
+
if (Object.keys(existing).length > 0) return;
|
|
158
|
+
this.importFromYaml(yamlPath);
|
|
159
|
+
try {
|
|
160
|
+
renameSync(yamlPath, yamlPath + ".bak");
|
|
161
|
+
console.log(`[config] Migrated ${yamlPath} → SQLite (backup: .bak)`);
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
|
164
|
+
this.migrateSessionMapIfNeeded();
|
|
165
|
+
this.migratePushSubsIfNeeded();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private importFromYaml(path: string): void {
|
|
169
|
+
try {
|
|
170
|
+
const yaml = require("js-yaml");
|
|
171
|
+
const raw = readFileSync(path, "utf-8");
|
|
172
|
+
const parsed = yaml.load(raw) as Partial<PpmConfig> | null;
|
|
173
|
+
if (!parsed) return;
|
|
174
|
+
const merged = { ...structuredClone(DEFAULT_CONFIG), ...parsed };
|
|
175
|
+
for (const key of CONFIG_TABLE_KEYS) {
|
|
176
|
+
const value = (merged as any)[key];
|
|
177
|
+
if (value !== undefined) {
|
|
178
|
+
setConfigValue(String(key), JSON.stringify(value));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (merged.projects?.length) {
|
|
182
|
+
this.syncProjectsToDb(merged.projects);
|
|
183
|
+
}
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error(`[config] Error importing YAML ${path}:`, (err as Error).message);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private migrateSessionMapIfNeeded(): void {
|
|
190
|
+
const mapPath = resolve(PPM_DIR, "session-map.json");
|
|
191
|
+
if (!existsSync(mapPath)) return;
|
|
192
|
+
try {
|
|
193
|
+
const { setSessionMapping } = require("./db.service.ts");
|
|
194
|
+
const map = JSON.parse(readFileSync(mapPath, "utf-8")) as Record<string, string>;
|
|
195
|
+
for (const [ppmId, sdkId] of Object.entries(map)) {
|
|
196
|
+
setSessionMapping(ppmId, sdkId);
|
|
197
|
+
}
|
|
198
|
+
renameSync(mapPath, mapPath + ".bak");
|
|
199
|
+
console.log("[config] Migrated session-map.json → SQLite");
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private migratePushSubsIfNeeded(): void {
|
|
204
|
+
const subsPath = resolve(PPM_DIR, "push-subscriptions.json");
|
|
205
|
+
if (!existsSync(subsPath)) return;
|
|
206
|
+
try {
|
|
207
|
+
const { upsertPushSubscription } = require("./db.service.ts");
|
|
208
|
+
const subs = JSON.parse(readFileSync(subsPath, "utf-8")) as Array<{
|
|
209
|
+
endpoint: string;
|
|
210
|
+
keys: { p256dh: string; auth: string };
|
|
211
|
+
expirationTime?: number | null;
|
|
212
|
+
}>;
|
|
213
|
+
for (const sub of subs) {
|
|
214
|
+
upsertPushSubscription(
|
|
215
|
+
sub.endpoint,
|
|
216
|
+
sub.keys.p256dh,
|
|
217
|
+
sub.keys.auth,
|
|
218
|
+
sub.expirationTime != null ? String(sub.expirationTime) : null,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
renameSync(subsPath, subsPath + ".bak");
|
|
222
|
+
console.log("[config] Migrated push-subscriptions.json → SQLite");
|
|
223
|
+
} catch {}
|
|
224
|
+
}
|
|
101
225
|
}
|
|
102
226
|
|
|
103
227
|
/** Singleton config service */
|