bom-pi 0.0.1 → 0.0.3
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.
|
@@ -97,7 +97,7 @@ function mapApproval(a) {
|
|
|
97
97
|
|
|
98
98
|
const OWNER = { id: 'bom-owner', name: 'Owner', role: 'owner' };
|
|
99
99
|
|
|
100
|
-
export function makePocketAdapter({ rpc, checkToken }) {
|
|
100
|
+
export function makePocketAdapter({ rpc, checkToken, push }) {
|
|
101
101
|
// me-shape: mọi thứ UI cần biết về server (chụp từ pocket 0.27.0 thật)
|
|
102
102
|
async function mePayload() {
|
|
103
103
|
const hello = await rpc('hello');
|
|
@@ -179,14 +179,14 @@ export function makePocketAdapter({ rpc, checkToken }) {
|
|
|
179
179
|
return async function pocketRoute(req, res, url, authed) {
|
|
180
180
|
const path = url.pathname.replace(/^\/api\//, '');
|
|
181
181
|
const parts = path.split('/').filter(Boolean);
|
|
182
|
-
const [first, second, third] = parts;
|
|
182
|
+
const [first, second, third, fourth] = parts;
|
|
183
183
|
const method = req.method;
|
|
184
184
|
const isPost = method !== 'GET' && method !== 'HEAD';
|
|
185
185
|
|
|
186
186
|
if (isPost && req.headers['x-pocket'] !== '1') return false; // POST pocket luôn có X-Pocket (api.ts l365)
|
|
187
187
|
if (!['me', 'login', 'logout', 'sessions', 'c', 'providers', 'themes', 'theme', 'events', 'poll',
|
|
188
188
|
'users', 'hooks', 'subagents', 'pip', 'extensions', 'extension-settings', 'fs', 'visibility', 'peeks',
|
|
189
|
-
'folder-favorites', 'spend', 'pi-sessions', 'x', 'repo-watches', 'approvals'].includes(first)) return false;
|
|
189
|
+
'folder-favorites', 'spend', 'pi-sessions', 'x', 'repo-watches', 'approvals', 'push'].includes(first)) return false;
|
|
190
190
|
|
|
191
191
|
try {
|
|
192
192
|
// ---- sign-in/lifecycle ----
|
|
@@ -243,6 +243,27 @@ export function makePocketAdapter({ rpc, checkToken }) {
|
|
|
243
243
|
}
|
|
244
244
|
if (third === 'turns' && isPost) return json(res, 200, { ok: true }), true; // PORT(bom-pi): multiplayer turns — chưa có
|
|
245
245
|
if (third === 'typing' && isPost) return json(res, 200, { ok: true }), true; // PORT(bom-pi): presence
|
|
246
|
+
// ---- Files tile + Changes review (repo thật của xưởng) ----
|
|
247
|
+
if (third === 'files' && method === 'GET') {
|
|
248
|
+
const r = await rpc('fs.list', { conversationId: id });
|
|
249
|
+
const since = url.searchParams.get('since');
|
|
250
|
+
const version = String(r.files.length) + ':' + (r.files[0] ?? '') + (r.files.at(-1) ?? '');
|
|
251
|
+
if (since === version) return json(res, 200, { version, same: true }), true;
|
|
252
|
+
return json(res, 200, { files: r.files, truncated: r.truncated, version }), true;
|
|
253
|
+
}
|
|
254
|
+
if (third === 'view' && method === 'GET') {
|
|
255
|
+
const r = await rpc('fs.view', { conversationId: id, path: url.searchParams.get('path') ?? '' });
|
|
256
|
+
return json(res, 200, { path: r.path, text: r.text }), true;
|
|
257
|
+
}
|
|
258
|
+
if (third === 'changes' && fourth === undefined && method === 'GET') {
|
|
259
|
+
return json(res, 200, await rpc('changes.get', { conversationId: id })), true;
|
|
260
|
+
}
|
|
261
|
+
if (third === 'changes' && fourth === 'diff' && method === 'GET') {
|
|
262
|
+
const r = await rpc('changes.diff', { conversationId: id, path: url.searchParams.get('path') ?? '' });
|
|
263
|
+
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
|
|
264
|
+
res.end(r.diff ?? '');
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
246
267
|
if (isPost) { await readBody(req); return json(res, 200, { ok: true }), true; } // PORT(bom-pi): plan/chat/pin/notes/fork/... — noop
|
|
247
268
|
return json(res, 200, {}), true;
|
|
248
269
|
}
|
|
@@ -254,6 +275,36 @@ export function makePocketAdapter({ rpc, checkToken }) {
|
|
|
254
275
|
return json(res, 200, true), true;
|
|
255
276
|
}
|
|
256
277
|
|
|
278
|
+
// ---- push: phone notifications (Web Push RFC 8291/8292/8030 — push.mjs port pi-pocket) ----
|
|
279
|
+
if (first === 'push') {
|
|
280
|
+
if (!push) return json(res, 503, { error: 'Push notifications are not available on this server.' }), true;
|
|
281
|
+
const { store, userId } = push;
|
|
282
|
+
if (second === undefined && method === 'GET') {
|
|
283
|
+
return json(res, 200, { publicKey: store.publicKey, prefs: store.prefs(userId), devices: store.subscriptions(userId).length }), true;
|
|
284
|
+
}
|
|
285
|
+
if (second === 'subscribe' && isPost) {
|
|
286
|
+
const body = await readBody(req);
|
|
287
|
+
try { store.subscribe(userId, body.subscription, String(req.headers['user-agent'] ?? '').slice(0, 200)); }
|
|
288
|
+
catch (e) { return json(res, 400, { error: String(e?.message ?? e) }), true; }
|
|
289
|
+
return json(res, 200, { ok: true, devices: store.subscriptions(userId).length }), true;
|
|
290
|
+
}
|
|
291
|
+
if (second === 'unsubscribe' && isPost) {
|
|
292
|
+
const body = await readBody(req);
|
|
293
|
+
store.unsubscribe(String(body.endpoint ?? ''), userId);
|
|
294
|
+
return json(res, 200, { ok: true, devices: store.subscriptions(userId).length }), true;
|
|
295
|
+
}
|
|
296
|
+
if (second === 'prefs' && isPost) {
|
|
297
|
+
const body = await readBody(req);
|
|
298
|
+
return json(res, 200, { prefs: store.setPrefs(userId, body) }), true;
|
|
299
|
+
}
|
|
300
|
+
if (second === 'test' && isPost) {
|
|
301
|
+
await readBody(req);
|
|
302
|
+
const sent = await store.notify(userId, { title: 'bom', body: 'Notifications work on this device.', url: '/', tag: 'test' });
|
|
303
|
+
if (sent === 0) return json(res, 409, { error: 'No device took the test notification. Turn notifications on again on this device.' }), true;
|
|
304
|
+
return json(res, 200, { sent }), true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
257
308
|
// ---- providers/themes ----
|
|
258
309
|
if (first === 'providers' && method === 'GET') {
|
|
259
310
|
const models = ((await rpc('models.list')).models ?? []).map(mapModel);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
3
|
-
"short_name": "
|
|
4
|
-
"description": "
|
|
2
|
+
"name": "bom",
|
|
3
|
+
"short_name": "bom",
|
|
4
|
+
"description": "bom × pi — durable factories of pi agents in your browser",
|
|
5
5
|
"start_url": "/",
|
|
6
6
|
"scope": "/",
|
|
7
7
|
"display": "standalone",
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// push.mjs — Web Push không dependency (port từ pi-pocket src/server/push.ts, MIT):
|
|
2
|
+
// RFC 8291 aes128gcm + RFC 8292 VAPID (ES256) + RFC 8030 POST. PushStore giữ VAPID keys,
|
|
3
|
+
// subscription mỗi user, prefs — push.json ở dataDir (atomic write 0600).
|
|
4
|
+
import {
|
|
5
|
+
createCipheriv, createECDH, createHash, createHmac, createPrivateKey, randomBytes, sign,
|
|
6
|
+
} from 'node:crypto';
|
|
7
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_PREFS = Object.freeze({ done: true, approval: true, chat: true, mention: true, limit: true });
|
|
11
|
+
const DEFAULT_SUBJECT = 'mailto:bom-pi@localhost';
|
|
12
|
+
const DEFAULT_RECORD_SIZE = 4096;
|
|
13
|
+
const MAX_BODY = 1000;
|
|
14
|
+
const MAX_TITLE = 200;
|
|
15
|
+
const MAX_DEVICES = 10;
|
|
16
|
+
|
|
17
|
+
const hmac = (key, ...data) => {
|
|
18
|
+
const mac = createHmac('sha256', key);
|
|
19
|
+
for (const part of data) mac.update(part);
|
|
20
|
+
return mac.digest();
|
|
21
|
+
};
|
|
22
|
+
const decode = (value) => Buffer.from(String(value).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'), 'base64url');
|
|
23
|
+
const pad32 = (value) => (value.length >= 32 ? value : Buffer.concat([Buffer.alloc(32 - value.length), value]));
|
|
24
|
+
|
|
25
|
+
export function generateVapidKeys() {
|
|
26
|
+
const ecdh = createECDH('prime256v1');
|
|
27
|
+
ecdh.generateKeys();
|
|
28
|
+
return { publicKey: ecdh.getPublicKey().toString('base64url'), privateKey: pad32(ecdh.getPrivateKey()).toString('base64url') };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** RFC 8291 aes128gcm body cho MỘT record. */
|
|
32
|
+
export function encryptPayload(plaintext, keys, options = {}) {
|
|
33
|
+
const data = typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf8') : plaintext;
|
|
34
|
+
const uaPublic = decode(keys.p256dh);
|
|
35
|
+
const authSecret = decode(keys.auth);
|
|
36
|
+
if (uaPublic.length !== 65 || uaPublic[0] !== 0x04) throw new Error('p256dh must be an uncompressed P-256 point');
|
|
37
|
+
if (authSecret.length !== 16) throw new Error('auth must be 16 bytes');
|
|
38
|
+
const recordSize = options.recordSize ?? DEFAULT_RECORD_SIZE;
|
|
39
|
+
if (data.length + 1 + 16 > recordSize) throw new Error(`push payload too large: ${data.length} bytes`);
|
|
40
|
+
const salt = options.salt ?? randomBytes(16);
|
|
41
|
+
const ecdh = createECDH('prime256v1');
|
|
42
|
+
if (options.serverKeys === undefined) ecdh.generateKeys();
|
|
43
|
+
else ecdh.setPrivateKey(options.serverKeys.privateKey);
|
|
44
|
+
const asPublic = ecdh.getPublicKey();
|
|
45
|
+
const ecdhSecret = ecdh.computeSecret(uaPublic);
|
|
46
|
+
const prkKey = hmac(authSecret, ecdhSecret);
|
|
47
|
+
const keyInfo = Buffer.concat([Buffer.from('WebPush: info\0', 'latin1'), uaPublic, asPublic]);
|
|
48
|
+
const ikm = hmac(prkKey, keyInfo, Buffer.from([1]));
|
|
49
|
+
const prk = hmac(salt, ikm);
|
|
50
|
+
const cek = hmac(prk, Buffer.from('Content-Encoding: aes128gcm\0\x01', 'latin1')).subarray(0, 16);
|
|
51
|
+
const nonce = hmac(prk, Buffer.from('Content-Encoding: nonce\0\x01', 'latin1')).subarray(0, 12);
|
|
52
|
+
const cipher = createCipheriv('aes-128-gcm', cek, nonce);
|
|
53
|
+
const padded = Buffer.concat([data, Buffer.from([2])]);
|
|
54
|
+
const ciphertext = Buffer.concat([cipher.update(padded), cipher.final(), cipher.getAuthTag()]);
|
|
55
|
+
const header = Buffer.alloc(16 + 4 + 1);
|
|
56
|
+
salt.copy(header, 0);
|
|
57
|
+
header.writeUInt32BE(recordSize, 16);
|
|
58
|
+
header.writeUInt8(asPublic.length, 20);
|
|
59
|
+
return Buffer.concat([header, asPublic, ciphertext]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Authorization `vapid t=<jwt>, k=<pub>` (ES256, ieee-p1363 raw r||s). */
|
|
63
|
+
export function vapidAuthorization(endpoint, vapid, subject, now = Date.now()) {
|
|
64
|
+
const publicKey = decode(vapid.publicKey);
|
|
65
|
+
if (publicKey.length !== 65 || publicKey[0] !== 0x04) throw new Error('VAPID public key must be an uncompressed P-256 point');
|
|
66
|
+
const key = createPrivateKey({
|
|
67
|
+
key: {
|
|
68
|
+
kty: 'EC', crv: 'P-256',
|
|
69
|
+
x: publicKey.subarray(1, 33).toString('base64url'),
|
|
70
|
+
y: publicKey.subarray(33, 65).toString('base64url'),
|
|
71
|
+
d: pad32(decode(vapid.privateKey)).toString('base64url'),
|
|
72
|
+
},
|
|
73
|
+
format: 'jwk',
|
|
74
|
+
});
|
|
75
|
+
const header = Buffer.from(JSON.stringify({ typ: 'JWT', alg: 'ES256' })).toString('base64url');
|
|
76
|
+
const claims = { aud: new URL(endpoint).origin, exp: Math.floor(now / 1000) + 12 * 60 * 60, sub: subject };
|
|
77
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
78
|
+
const signingInput = `${header}.${payload}`;
|
|
79
|
+
const signature = sign('sha256', Buffer.from(signingInput), { key, dsaEncoding: 'ieee-p1363' });
|
|
80
|
+
return `vapid t=${signingInput}.${signature.toString('base64url')}, k=${publicKey.toString('base64url')}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const topicFor = (tag) => (/^[A-Za-z0-9_-]{1,32}$/.test(tag) ? tag : createHash('sha256').update(tag).digest('base64url').slice(0, 32));
|
|
84
|
+
const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
85
|
+
|
|
86
|
+
/** POST bản mã tới push service — trả HTTP status. */
|
|
87
|
+
export async function sendPush(subscription, message, vapid, options = {}) {
|
|
88
|
+
// JS không có type-check như TS gốc — mặc định an toàn khi trường thiếu
|
|
89
|
+
const payload = { title: clip(String(message.title ?? 'bom'), MAX_TITLE), body: clip(String(message.body ?? ''), MAX_BODY), url: message.url };
|
|
90
|
+
if (message.tag !== undefined) payload.tag = message.tag;
|
|
91
|
+
if (message.approval !== undefined) payload.approval = message.approval;
|
|
92
|
+
const body = encryptPayload(JSON.stringify(payload), subscription.keys);
|
|
93
|
+
const headers = {
|
|
94
|
+
'Content-Encoding': 'aes128gcm',
|
|
95
|
+
'Content-Type': 'application/octet-stream',
|
|
96
|
+
TTL: String(options.ttl ?? 24 * 60 * 60),
|
|
97
|
+
Urgency: options.urgency ?? 'normal',
|
|
98
|
+
Authorization: vapidAuthorization(subscription.endpoint, vapid, options.subject ?? DEFAULT_SUBJECT),
|
|
99
|
+
};
|
|
100
|
+
if (message.tag !== undefined && message.tag !== '') headers.Topic = topicFor(message.tag);
|
|
101
|
+
const doFetch = options.fetch ?? fetch;
|
|
102
|
+
const response = await doFetch(subscription.endpoint, { method: 'POST', headers, body: new Uint8Array(body) });
|
|
103
|
+
await response.arrayBuffer().catch(() => undefined);
|
|
104
|
+
return response.status;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Chỉ nhận endpoint của các push service trình duyệt thật (chặn dùng server dò địa chỉ nội bộ)
|
|
108
|
+
const PUSH_HOSTS = [
|
|
109
|
+
/^fcm\.googleapis\.com$/,
|
|
110
|
+
/^android\.googleapis\.com$/,
|
|
111
|
+
/(^|\.)push\.services\.mozilla\.com$/,
|
|
112
|
+
/(^|\.)notify\.windows\.com$/,
|
|
113
|
+
/(^|\.)push\.apple\.com$/,
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
export function validateSubscription(subscription) {
|
|
117
|
+
if (subscription === null || typeof subscription !== 'object') throw new Error('subscription must be an object');
|
|
118
|
+
if (typeof subscription.endpoint !== 'string') throw new Error('subscription endpoint is missing');
|
|
119
|
+
let url;
|
|
120
|
+
try { url = new URL(subscription.endpoint); } catch { throw new Error('subscription endpoint is not a valid URL'); }
|
|
121
|
+
if (url.protocol !== 'https:') throw new Error('subscription endpoint must be an https URL');
|
|
122
|
+
if (url.port !== '' || url.username !== '' || url.password !== '' || !PUSH_HOSTS.some((host) => host.test(url.hostname))) {
|
|
123
|
+
throw new Error(`${url.host} is not a browser push service`);
|
|
124
|
+
}
|
|
125
|
+
const keys = subscription.keys;
|
|
126
|
+
if (keys === null || typeof keys !== 'object' || typeof keys.p256dh !== 'string' || typeof keys.auth !== 'string') {
|
|
127
|
+
throw new Error('subscription keys.p256dh and keys.auth are required');
|
|
128
|
+
}
|
|
129
|
+
const p256dh = decode(keys.p256dh);
|
|
130
|
+
if (p256dh.length !== 65 || p256dh[0] !== 0x04) throw new Error('subscription keys.p256dh must be a 65-byte uncompressed P-256 public key');
|
|
131
|
+
if (decode(keys.auth).length !== 16) throw new Error('subscription keys.auth must be 16 bytes');
|
|
132
|
+
return { endpoint: subscription.endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** push.json ở dataDir — VAPID sinh lần đầu, subscriptions + prefs mỗi user. */
|
|
136
|
+
export class PushStore {
|
|
137
|
+
constructor(directory) {
|
|
138
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
139
|
+
this.file = join(directory, 'push.json');
|
|
140
|
+
if (existsSync(this.file)) {
|
|
141
|
+
const loaded = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
142
|
+
this.data = { version: 1, vapid: loaded.vapid ?? generateVapidKeys(), subscriptions: loaded.subscriptions ?? [], prefs: loaded.prefs ?? {} };
|
|
143
|
+
if (loaded.vapid === undefined) this.save();
|
|
144
|
+
} else {
|
|
145
|
+
this.data = { version: 1, vapid: generateVapidKeys(), subscriptions: [], prefs: {} };
|
|
146
|
+
this.save();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
get publicKey() { return this.data.vapid.publicKey; }
|
|
150
|
+
get vapid() { return { ...this.data.vapid }; }
|
|
151
|
+
subscriptions(userId) {
|
|
152
|
+
return this.data.subscriptions.filter((s) => userId === undefined || s.userId === userId).map((s) => ({ ...s }));
|
|
153
|
+
}
|
|
154
|
+
subscribe(userId, subscription, userAgent) {
|
|
155
|
+
const clean = validateSubscription(subscription);
|
|
156
|
+
const stored = { ...clean, userId, createdAt: Date.now() };
|
|
157
|
+
if (userAgent !== undefined && userAgent !== '') stored.userAgent = String(userAgent).slice(0, 300);
|
|
158
|
+
const others = this.data.subscriptions.filter((s) => s.endpoint !== clean.endpoint);
|
|
159
|
+
const theirs = others.filter((s) => s.userId === userId);
|
|
160
|
+
const dropped = new Set(theirs.slice(0, Math.max(0, theirs.length - (MAX_DEVICES - 1))));
|
|
161
|
+
this.data.subscriptions = [...others.filter((s) => !dropped.has(s)), stored];
|
|
162
|
+
this.save();
|
|
163
|
+
}
|
|
164
|
+
unsubscribe(endpoint, userId) {
|
|
165
|
+
const before = this.data.subscriptions.length;
|
|
166
|
+
this.data.subscriptions = this.data.subscriptions.filter(
|
|
167
|
+
(s) => s.endpoint !== endpoint || (userId !== undefined && s.userId === userId),
|
|
168
|
+
);
|
|
169
|
+
if (this.data.subscriptions.length === before) return false;
|
|
170
|
+
this.save();
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
prefs(userId) { return { ...DEFAULT_PREFS, ...this.data.prefs[userId] }; }
|
|
174
|
+
setPrefs(userId, patch) {
|
|
175
|
+
const stored = { ...this.data.prefs[userId] };
|
|
176
|
+
for (const name of Object.keys(DEFAULT_PREFS)) {
|
|
177
|
+
if (typeof patch[name] === 'boolean') stored[name] = patch[name];
|
|
178
|
+
}
|
|
179
|
+
this.data.prefs[userId] = stored;
|
|
180
|
+
this.save();
|
|
181
|
+
return this.prefs(userId);
|
|
182
|
+
}
|
|
183
|
+
/** Gửi tới mọi subscription của user; bỏ subscription chết (404/410). Không throw; trả số 2xx. */
|
|
184
|
+
async notify(userId, message, options = {}) {
|
|
185
|
+
const targets = this.subscriptions(userId);
|
|
186
|
+
if (targets.length === 0) return 0;
|
|
187
|
+
const vapid = this.vapid;
|
|
188
|
+
const results = await Promise.allSettled(targets.map((sub) => sendPush(sub, message, vapid, options)));
|
|
189
|
+
let accepted = 0;
|
|
190
|
+
const gone = new Set();
|
|
191
|
+
results.forEach((r, index) => {
|
|
192
|
+
if (r.status !== 'fulfilled') return;
|
|
193
|
+
if (r.value >= 200 && r.value < 300) accepted++;
|
|
194
|
+
else if (r.value === 404 || r.value === 410) gone.add(targets[index]?.endpoint ?? '');
|
|
195
|
+
});
|
|
196
|
+
if (gone.size > 0) {
|
|
197
|
+
try {
|
|
198
|
+
this.data.subscriptions = this.data.subscriptions.filter((s) => !(s.userId === userId && gone.has(s.endpoint)));
|
|
199
|
+
this.save();
|
|
200
|
+
} catch { /* lưu fail — thử lại lần tới */ }
|
|
201
|
+
}
|
|
202
|
+
return accepted;
|
|
203
|
+
}
|
|
204
|
+
save() {
|
|
205
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
|
|
206
|
+
const temp = `${this.file}.${process.pid}.tmp`;
|
|
207
|
+
writeFileSync(temp, `${JSON.stringify(this.data, null, '\t')}\n`, { mode: 0o600 });
|
|
208
|
+
renameSync(temp, this.file);
|
|
209
|
+
chmodSync(this.file, 0o600);
|
|
210
|
+
}
|
|
211
|
+
}
|
package/apps/web/server.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import process from 'node:process';
|
|
17
17
|
import { COOKIE, makePocketAdapter } from './pocket-adapter.mjs';
|
|
18
|
+
import { PushStore } from './push.mjs';
|
|
18
19
|
|
|
19
20
|
// UI pocket (clone từ TannerMidd/pi-pocket, MIT) nằm ở public/ — serve tĩnh
|
|
20
21
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -122,7 +123,8 @@ const authorized = async (req, url) => {
|
|
|
122
123
|
};
|
|
123
124
|
|
|
124
125
|
// ---- Events fan-out cho pocket adapter: một socket dài, events.update của daemon
|
|
125
|
-
// báo mọi client SSE pocket làm mới (pull-on-push).
|
|
126
|
+
// báo mọi client SSE pocket làm mới (pull-on-push). Socket sống độc lập SSE client —
|
|
127
|
+
// push notification phải nổ khi KHÔNG ai mở web, nên tạo ngay từ boot + tự nối lại. ----
|
|
126
128
|
const eventListeners = new Set();
|
|
127
129
|
const watchedConvs = new Set();
|
|
128
130
|
let eventsSocket = null;
|
|
@@ -145,18 +147,26 @@ async function ensureEventsSocket(cid) {
|
|
|
145
147
|
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
|
|
146
148
|
try {
|
|
147
149
|
const msg = JSON.parse(line);
|
|
148
|
-
if (msg.method === 'events.update')
|
|
150
|
+
if (msg.method === 'events.update') {
|
|
151
|
+
for (const fn of eventListeners) fn(msg.params);
|
|
152
|
+
pushApprovalWatch(); // push "needs approval" — kể cả khi tab đóng
|
|
153
|
+
}
|
|
149
154
|
} catch { /* dòng chưa trọn */ }
|
|
150
155
|
}
|
|
151
156
|
});
|
|
152
|
-
conn.on('close', () => { eventsSocket = null; watchedConvs.clear(); });
|
|
153
|
-
conn.on('error', () => { eventsSocket = null; });
|
|
157
|
+
conn.on('close', () => { eventsSocket = null; watchedConvs.clear(); scheduleEventsReconnect(); });
|
|
158
|
+
conn.on('error', () => { eventsSocket = null; scheduleEventsReconnect(); });
|
|
154
159
|
conn.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'events.subscribe', params: {} }) + '\n');
|
|
155
160
|
if (Number.isInteger(cid) && cid > 0) {
|
|
156
161
|
watchedConvs.add(cid);
|
|
157
162
|
conn.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'events.subscribe', params: { conversationId: cid } }) + '\n');
|
|
158
163
|
}
|
|
159
164
|
}
|
|
165
|
+
let reconnectTimer = null;
|
|
166
|
+
function scheduleEventsReconnect() {
|
|
167
|
+
if (reconnectTimer) return;
|
|
168
|
+
reconnectTimer = setTimeout(() => { reconnectTimer = null; ensureEventsSocket().catch(() => scheduleEventsReconnect()); }, 2000);
|
|
169
|
+
}
|
|
160
170
|
rpc.__subscribeEvents = async (cid, onChange) => {
|
|
161
171
|
await ensureEventsSocket(cid);
|
|
162
172
|
eventListeners.add(onChange);
|
|
@@ -166,7 +176,51 @@ rpc.__subscribeEvents = async (cid, onChange) => {
|
|
|
166
176
|
// ---- pocket wire (cookie pocket_auth — sign-in qua /login?token= sau khi pair) ----
|
|
167
177
|
const cookieToken = (req) => (req.headers.cookie ?? '').match(new RegExp(`${COOKIE}=([^;]+)`))?.[1] ?? null;
|
|
168
178
|
const pocketCookieOk = async (req) => { const t = cookieToken(req); return t && (TOKEN ? t === TOKEN || await verifyDeviceToken(t) : await verifyDeviceToken(t)); };
|
|
169
|
-
|
|
179
|
+
|
|
180
|
+
// ---- Web Push (port pi-pocket: RFC 8291/8292/8030, không dependency — push.mjs) ----
|
|
181
|
+
// push.json ở dataDir daemon (~/.local/state/bom-pi) — VAPID keys ổn định qua restart,
|
|
182
|
+
// subscriptions + prefs mỗi user. pushApprovalWatch() theo dõi approval mới qua events.update
|
|
183
|
+
// và bắn notification "bom needs approval" tới mọi thiết bị đã subscribe (kể cả tab đóng). ----
|
|
184
|
+
const DATA_DIR = resolve(process.env.BOM_PI_STATE_DIR || process.env.XDG_STATE_HOME || join(process.env.HOME ?? '/home', '.local', 'state'), 'bom-pi');
|
|
185
|
+
let pushStore = null;
|
|
186
|
+
try { pushStore = new PushStore(DATA_DIR); } catch (e) { console.error(`push disabled: ${e.message}`); }
|
|
187
|
+
const PUSH_USER = 'bom-owner';
|
|
188
|
+
let knownApprovalIds = null; // null = chưa nạp mốc — lần đầu không bắn loạt approval cũ
|
|
189
|
+
let approvalWatchTimer = null;
|
|
190
|
+
function pushApprovalWatch() {
|
|
191
|
+
if (!pushStore || approvalWatchTimer) return;
|
|
192
|
+
approvalWatchTimer = setTimeout(() => {
|
|
193
|
+
approvalWatchTimer = null;
|
|
194
|
+
void (async () => {
|
|
195
|
+
try {
|
|
196
|
+
if (pushStore.subscriptions(PUSH_USER).length === 0) { knownApprovalIds = null; return; } // chưa ai subscribe — bỏ qua
|
|
197
|
+
const pending = (await rpc('approvals.list')).pending ?? [];
|
|
198
|
+
const ids = new Set(pending.map((a) => String(a.id)));
|
|
199
|
+
if (knownApprovalIds === null) { knownApprovalIds = ids; return; }
|
|
200
|
+
const fresh = pending.filter((a) => !knownApprovalIds.has(String(a.id)));
|
|
201
|
+
knownApprovalIds = ids;
|
|
202
|
+
if (fresh.length === 0 || !pushStore.prefs(PUSH_USER).approval) return;
|
|
203
|
+
for (const a of fresh) {
|
|
204
|
+
// Khuôn alerts.ts của pocket: Allow từ notification chỉ khi cả call ngắn + không ký tự ngụy trang
|
|
205
|
+
const subject = String(a.command ?? a.summary ?? '');
|
|
206
|
+
const call = `${a.tool ?? 'call'}: ${subject}`.slice(0, 120);
|
|
207
|
+
const shown = call.length <= 120 && !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(subject);
|
|
208
|
+
void pushStore.notify(PUSH_USER, {
|
|
209
|
+
title: 'bom needs approval',
|
|
210
|
+
body: call || 'A tool call is waiting for you.',
|
|
211
|
+
url: `/s/${Number(a.conversationId ?? 0)}`,
|
|
212
|
+
tag: `approval-${a.id}`,
|
|
213
|
+
approval: { id: String(a.id), allow: shown },
|
|
214
|
+
}, { urgency: 'high' })
|
|
215
|
+
.then((sent) => console.error(`push approval ${String(a.id).slice(0, 24)} → ${sent} thiết bị`))
|
|
216
|
+
.catch((e) => console.error(`push approval ${String(a.id).slice(0, 24)} lỗi: ${e.message}`));
|
|
217
|
+
}
|
|
218
|
+
} catch { /* daemon bận — events.update kế tiếp thử lại */ }
|
|
219
|
+
})();
|
|
220
|
+
}, 500);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const pocketRoute = makePocketAdapter({ rpc, checkToken: async (t) => (TOKEN ? t === TOKEN : false) || (await verifyDeviceToken(t)), push: pushStore ? { store: pushStore, userId: PUSH_USER } : null });
|
|
170
224
|
|
|
171
225
|
// ---- HTML (mobile-first, không framework) ----
|
|
172
226
|
const HTML = `<!doctype html>
|
|
@@ -593,18 +647,6 @@ async function go(){
|
|
|
593
647
|
}
|
|
594
648
|
</script></body></html>`;
|
|
595
649
|
|
|
596
|
-
// ---- PWA (M8): install màn hình chính + notification approvals ----
|
|
597
|
-
const MANIFEST = {
|
|
598
|
-
name: 'bom', short_name: 'bom', start_url: '/', display: 'standalone',
|
|
599
|
-
background_color: '#101418', theme_color: '#101418',
|
|
600
|
-
icons: [{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
|
|
601
|
-
};
|
|
602
|
-
const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#101418"/><text x="50" y="68" font-size="56" text-anchor="middle" fill="#58a6ff" font-family="monospace" font-weight="bold">b</text></svg>`;
|
|
603
|
-
const SW_JS = `// bom service worker — network-first, không cache (app luôn tươi; SW chỉ để installable PWA)
|
|
604
|
-
self.addEventListener('install', () => self.skipWaiting());
|
|
605
|
-
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
|
|
606
|
-
self.addEventListener('fetch', () => {});`;
|
|
607
|
-
|
|
608
650
|
const http = createHttpServer(async (req, res) => {
|
|
609
651
|
const url = new URL(req.url, 'http://x');
|
|
610
652
|
if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
|
|
@@ -626,19 +668,9 @@ const http = createHttpServer(async (req, res) => {
|
|
|
626
668
|
try { return json2(res, 200, await rpc('pair.poll', { enrollId: p.enrollId })); }
|
|
627
669
|
catch (e) { return json2(res, 404, { error: e.message.replace(/^\d+: /, '') }); }
|
|
628
670
|
}
|
|
629
|
-
if (req.method === 'GET' && url.pathname === '/manifest.webmanifest') {
|
|
630
|
-
res.writeHead(200, { 'content-type': 'application/manifest+json', ...CORS });
|
|
631
|
-
return res.end(JSON.stringify(MANIFEST));
|
|
632
|
-
}
|
|
633
|
-
if (req.method === 'GET' && url.pathname === '/icon.svg') {
|
|
634
|
-
res.writeHead(200, { 'content-type': 'image/svg+xml', ...CORS });
|
|
635
|
-
return res.end(ICON_SVG);
|
|
636
|
-
}
|
|
637
|
-
if (req.method === 'GET' && url.pathname === '/sw.js') {
|
|
638
|
-
res.writeHead(200, { 'content-type': 'application/javascript', ...CORS });
|
|
639
|
-
return res.end(SW_JS);
|
|
640
|
-
}
|
|
641
671
|
// ---- UI pocket (public/): shell + module + vendor tĩnh, KHÔNG cần token ----
|
|
672
|
+
// serveStatic phục vụ cả sw.js/manifest/icon của pocket (public/) — không còn hằng
|
|
673
|
+
// SW cũ: sw.js pocket có push handler (notification từ push service).
|
|
642
674
|
if (req.method === 'GET' && await serveStatic(res, url)) return;
|
|
643
675
|
// ---- /login?token= : sign-in pocket UI bằng device token (sau pair) hoặc master ----
|
|
644
676
|
if (req.method === 'GET' && url.pathname === '/login') {
|
|
@@ -770,5 +802,7 @@ const http = createHttpServer(async (req, res) => {
|
|
|
770
802
|
});
|
|
771
803
|
|
|
772
804
|
http.listen(PORT, LISTEN, () => {
|
|
773
|
-
console.error(`bom-web listening http://${LISTEN}:${PORT} — bom-core at ${socketPath}${TOKEN ? ' — token required' : ' — NO TOKEN (loopback only)'}`);
|
|
805
|
+
console.error(`bom-web listening http://${LISTEN}:${PORT} — bom-core at ${socketPath}${TOKEN ? ' — token required' : ' — NO TOKEN (loopback only)'}${pushStore ? ` — push ${pushStore.publicKey.slice(0, 12)}…` : ' — push OFF'}`);
|
|
806
|
+
// Socket events sống từ boot: push approval phải nổ khi không ai mở web
|
|
807
|
+
ensureEventsSocket().catch(() => scheduleEventsReconnect());
|
|
774
808
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bom-pi",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "bom × pi — hệ sinh thái durable cho pi trên nền pi-durable: core daemon + web UI pocket + CLI",
|
|
6
6
|
"engines": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"packages/cli/templates",
|
|
18
18
|
"apps/web/server.mjs",
|
|
19
19
|
"apps/web/pocket-adapter.mjs",
|
|
20
|
+
"apps/web/push.mjs",
|
|
20
21
|
"apps/web/public",
|
|
21
22
|
"systemd",
|
|
22
23
|
"README.md"
|
|
@@ -27,6 +27,25 @@ const cid = arg('cid', null); // conversation id cho submit/transcript/reset/wat
|
|
|
27
27
|
const cidParams = () => (cid ? { conversationId: Number(cid) } : {});
|
|
28
28
|
const cmd = process.argv[2] ?? 'status';
|
|
29
29
|
|
|
30
|
+
// Không thấy daemon: hướng dẫn theo nơi đang chạy — máy chủ (chưa start?) hay
|
|
31
|
+
// thiết bị khác (Windows/phone: bom-core là daemon unix-socket systemd, chưa port).
|
|
32
|
+
function unreachable(e) {
|
|
33
|
+
const head = `cannot reach bom-core at ${socketPath}: ${e.message}`;
|
|
34
|
+
if (process.platform === 'win32') {
|
|
35
|
+
return `${head}
|
|
36
|
+
|
|
37
|
+
bom-core chạy trên máy chủ Linux (unix socket + systemd) — CLI chưa chạy được trên Windows.
|
|
38
|
+
Từ thiết bị khác, dùng WEB thay CLI: mở http://<ip-máy-chạy-daemon>:8791 (hoặc URL tunnel
|
|
39
|
+
https://… nếu có) rồi pair bằng code in ra từ lệnh 'bom-pi pair' chạy TRÊN MÁY CHỦ đó.`;
|
|
40
|
+
}
|
|
41
|
+
return `${head}
|
|
42
|
+
|
|
43
|
+
daemon chưa chạy? thử: systemctl --user start bom-core (xem trạng thái: bom-pi status)
|
|
44
|
+
chưa cài daemon?: bom-pi setup
|
|
45
|
+
ở THIẾT BỊ KHÁC?: CLI nói chuyện daemon trên MÁY NÀY — từ phone/laptop hãy mở
|
|
46
|
+
http://<ip-máy-chạy-daemon>:8791 và pair bằng code từ 'bom-pi pair'.`;
|
|
47
|
+
}
|
|
48
|
+
|
|
30
49
|
function rpc(method, params) {
|
|
31
50
|
return new Promise((done, fail) => {
|
|
32
51
|
const conn = connect(socketPath);
|
|
@@ -57,7 +76,7 @@ function rpc(method, params) {
|
|
|
57
76
|
});
|
|
58
77
|
// Daemon chết giữa chừng / đóng connection không trả lời
|
|
59
78
|
conn.on('close', () => settle(fail, new Error('bom-core closed the connection without a reply')));
|
|
60
|
-
conn.on('error', (e) => settle(fail, new Error(
|
|
79
|
+
conn.on('error', (e) => settle(fail, new Error(unreachable(e))));
|
|
61
80
|
});
|
|
62
81
|
}
|
|
63
82
|
|
|
@@ -99,7 +118,7 @@ function watch() {
|
|
|
99
118
|
}
|
|
100
119
|
}
|
|
101
120
|
});
|
|
102
|
-
conn.on('error', (e) => { console.error(
|
|
121
|
+
conn.on('error', (e) => { console.error(unreachable(e)); process.exit(1); });
|
|
103
122
|
conn.on('close', () => { if (!gotReply) { console.error('bom-core closed the connection without a reply'); process.exit(1); } process.exit(0); });
|
|
104
123
|
}
|
|
105
124
|
const printEntry = (e) => console.log(`[${e.kind}]${e.role ? ` ${e.role}:` : ''} ${e.text ?? ''}`);
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { connect, createServer } from 'node:net';
|
|
27
27
|
import { mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
28
28
|
import { chmodSync } from 'node:fs';
|
|
29
|
-
import { dirname, join, resolve } from 'node:path';
|
|
29
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
30
30
|
import { homedir, userInfo } from 'node:os';
|
|
31
31
|
import process from 'node:process';
|
|
32
32
|
import { execFile as execFileCb } from 'node:child_process';
|
|
@@ -71,6 +71,12 @@ async function shutdown(reason) {
|
|
|
71
71
|
if (closing) return;
|
|
72
72
|
closing = true;
|
|
73
73
|
log('đóng:', reason);
|
|
74
|
+
// Turn đang chờ approval không bao giờ settle ⇒ Session.close kẹt mãi.
|
|
75
|
+
// Từ chối hết mọi approval treo để turn kết thúc, rồi mới đóng storage.
|
|
76
|
+
for (const [, p] of pendingApprovals) {
|
|
77
|
+
try { p.resolve({ allow: false, reason: 'daemon shutdown' }); } catch { /* resolve rồi */ }
|
|
78
|
+
}
|
|
79
|
+
pendingApprovals.clear();
|
|
74
80
|
let failed = false;
|
|
75
81
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
76
82
|
if (scheduleTimer) clearInterval(scheduleTimer);
|
|
@@ -1614,6 +1620,91 @@ const methods = {
|
|
|
1614
1620
|
const entries = (view.entries ?? []).map((e) => ({ id: e.id, kind: e.kind, model: e.model ?? null }));
|
|
1615
1621
|
return { conversationId: conv.id, count: entries.length, entries };
|
|
1616
1622
|
},
|
|
1623
|
+
// ---- Files & Changes (pocket Files tile / Changes review) ----
|
|
1624
|
+
// cwd của conversation từ agent config; path client bị nhốt trong cwd (traversal chặn).
|
|
1625
|
+
async convCwd(params) {
|
|
1626
|
+
const conv = await convFor(params);
|
|
1627
|
+
const agent = await conv.agent(BACKGROUND_CONTEXT);
|
|
1628
|
+
return { conversationId: conv.id, cwd: agent?.cwd ?? process.env.HOME ?? process.cwd() };
|
|
1629
|
+
},
|
|
1630
|
+
async fsList(params) {
|
|
1631
|
+
const { cwd } = await methods.convCwd(params);
|
|
1632
|
+
const files = [];
|
|
1633
|
+
let truncated = false;
|
|
1634
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.cache']);
|
|
1635
|
+
const walk = async (rel) => {
|
|
1636
|
+
if (files.length >= 2000) { truncated = true; return; }
|
|
1637
|
+
let entries;
|
|
1638
|
+
try { entries = await readdir(join(cwd, rel), { withFileTypes: true }); } catch { return; }
|
|
1639
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
1640
|
+
for (const e of entries) {
|
|
1641
|
+
if (e.name.startsWith('.') || SKIP.has(e.name)) continue;
|
|
1642
|
+
const p = rel ? `${rel}/${e.name}` : e.name;
|
|
1643
|
+
if (e.isDirectory()) await walk(p);
|
|
1644
|
+
else { files.push(p); if (files.length >= 2000) { truncated = true; break; } }
|
|
1645
|
+
}
|
|
1646
|
+
};
|
|
1647
|
+
await walk('');
|
|
1648
|
+
return { files, truncated };
|
|
1649
|
+
},
|
|
1650
|
+
async fsView(params) {
|
|
1651
|
+
const { cwd } = await methods.convCwd(params);
|
|
1652
|
+
const p = resolve(cwd, String(params?.path ?? ''));
|
|
1653
|
+
if (p !== cwd && !p.startsWith(cwd + sep)) throw new Error('path ngoài thư mục làm việc');
|
|
1654
|
+
const st = await stat(p);
|
|
1655
|
+
if (st.size > 1_000_000) throw new Error('file quá lớn (>1MB)');
|
|
1656
|
+
return { path: String(params.path), text: await readFile(p, 'utf8') };
|
|
1657
|
+
},
|
|
1658
|
+
async changesGet(params) {
|
|
1659
|
+
const { cwd } = await methods.convCwd(params);
|
|
1660
|
+
let root = '';
|
|
1661
|
+
try { root = (await execFileAsync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { timeout: 10_000 })).stdout.trim(); } catch { return { repo: null, files: [], more: 0, piOnly: [] }; }
|
|
1662
|
+
const branch = (await execFileAsync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], { timeout: 10_000 })).stdout.trim();
|
|
1663
|
+
const head = (await execFileAsync('git', ['-C', cwd, 'rev-parse', 'HEAD'], { timeout: 10_000 })).stdout.trim();
|
|
1664
|
+
const st = (await execFileAsync('git', ['-C', cwd, 'status', '--porcelain=v1', '-uall'], { timeout: 10_000 })).stdout;
|
|
1665
|
+
let numstat = '';
|
|
1666
|
+
try { numstat = (await execFileAsync('git', ['-C', cwd, 'diff', 'HEAD', '--numstat'], { timeout: 10_000 })).stdout; } catch { numstat = ''; }
|
|
1667
|
+
const statMap = new Map();
|
|
1668
|
+
for (const line of numstat.split('\n').filter(Boolean)) {
|
|
1669
|
+
const [a, r, p] = line.split('\t');
|
|
1670
|
+
statMap.set(p, { added: a === '-' ? 0 : Number(a), removed: r === '-' ? 0 : Number(r) });
|
|
1671
|
+
}
|
|
1672
|
+
const KIND = { M: 'modified', A: 'new', '?': 'new', D: 'deleted', R: 'renamed' };
|
|
1673
|
+
const files = [];
|
|
1674
|
+
for (const line of st.split('\n').filter(Boolean)) {
|
|
1675
|
+
const x = line[0] === ' ' ? '?' : line[0]; // staged letter; '?' = untracked
|
|
1676
|
+
const path = line.slice(3).trim();
|
|
1677
|
+
let s = statMap.get(path) ?? { added: 0, removed: 0 };
|
|
1678
|
+
if (s.added === 0 && s.removed === 0 && (KIND[x] === 'new')) {
|
|
1679
|
+
// untracked: numstat không có — đếm dòng file (diff full là +all)
|
|
1680
|
+
try { s = { added: (await readFile(join(cwd, path), 'utf8')).split('\n').filter(Boolean).length, removed: 0 }; } catch { /* binary/đã đổi */ }
|
|
1681
|
+
}
|
|
1682
|
+
files.push({ path, kind: KIND[x] ?? 'modified', added: s.added, removed: s.removed, byPi: false, version: `1:${Date.now()}` });
|
|
1683
|
+
}
|
|
1684
|
+
return { repo: { root, branch, head }, files, more: 0, piOnly: [] };
|
|
1685
|
+
},
|
|
1686
|
+
async changesDiff(params) {
|
|
1687
|
+
const { cwd } = await methods.convCwd(params);
|
|
1688
|
+
const p = resolve(cwd, String(params?.path ?? ''));
|
|
1689
|
+
if (p !== cwd && !p.startsWith(cwd + sep)) throw new Error('path ngoài thư mục làm việc');
|
|
1690
|
+
const rel = relative(cwd, p);
|
|
1691
|
+
try {
|
|
1692
|
+
const tracked = (await execFileAsync('git', ['-C', cwd, 'ls-files', '--error-unmatch', rel], { timeout: 10_000 })).stdout.trim();
|
|
1693
|
+
if (tracked) {
|
|
1694
|
+
const { stdout } = await execFileAsync('git', ['-C', cwd, 'diff', 'HEAD', '--', rel], { timeout: 20_000, maxBuffer: 4 * 1024 * 1024 });
|
|
1695
|
+
return { diff: stdout };
|
|
1696
|
+
}
|
|
1697
|
+
} catch { /* untracked hoặc lỗi — rơi về --no-index */ }
|
|
1698
|
+
try {
|
|
1699
|
+
// -C + path relative để header diff đúng b/<rel> (absolute path làm header a/<cwd>/...)
|
|
1700
|
+
const { stdout } = await execFileAsync('git', ['-C', cwd, 'diff', '--no-index', '--', '/dev/null', rel], { timeout: 20_000, maxBuffer: 4 * 1024 * 1024 });
|
|
1701
|
+
return { diff: stdout };
|
|
1702
|
+
} catch (e) {
|
|
1703
|
+
// git diff --no-index exit 1 khi CÓ khác biệt — stdout vẫn chứa diff
|
|
1704
|
+
if (e?.stdout) return { diff: String(e.stdout) };
|
|
1705
|
+
throw e;
|
|
1706
|
+
}
|
|
1707
|
+
},
|
|
1617
1708
|
async eventsSubscribe(params, ctx) {
|
|
1618
1709
|
const conv = await convFor(params);
|
|
1619
1710
|
const w = await ensureWatch(conv.id, conv);
|