@my-life-buddies/cli 0.23.0-dev.1 → 0.23.0-rc.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/dist/web/app.js CHANGED
@@ -961,9 +961,9 @@
961
961
  if (!response.ok) throw new Error(value.error || "连接状态读取失败");
962
962
  if (epoch !== connectionEpoch) return;
963
963
  connectionChecked = true; connectionFailure = "";
964
- $("#connect-command").textContent = value.command.replace(/^buddy-cli(?= )/, "buddy-cli-sit");
964
+ $("#connect-command").textContent = value.command.replace(/^buddy-cli(?= )/, "buddy-cli-prod");
965
965
  $("#copy-connect-command").disabled = false;
966
- $("#stop-command").textContent = value.stopCommand?.replace(/^buddy-cli(?= )/, "buddy-cli-sit") || "buddy-cli-sit dev stop";
966
+ $("#stop-command").textContent = value.stopCommand?.replace(/^buddy-cli(?= )/, "buddy-cli-prod") || "buddy-cli-prod dev stop";
967
967
  if (value.projectRoot) $("#project-root").textContent = value.projectRoot;
968
968
  if (value.machineName) $("#machine-name").textContent = value.machineName;
969
969
  if (!value.connected) { connectionInstance = value.instanceId || ""; disconnected(); return; }
@@ -0,0 +1,41 @@
1
+ // 注入隔离的 Widget 文档;只允许向宿主发送固定方法,不提供身份、Token 或网络代理。
2
+ ((render) => {
3
+ let counter = 0, closed = false, gesture = 0;
4
+ const pending = new Map();
5
+ document.addEventListener('click', event => { if (event.isTrusted) gesture = Date.now(); }, true);
6
+ function request(method, params = {}) {
7
+ if (closed) return Promise.reject(new Error('小挂件已关闭'));
8
+ const requestId = `${render}-${++counter}`;
9
+ return new Promise((resolve, reject) => {
10
+ const timer = setTimeout(() => { pending.delete(requestId); reject(new Error('操作等待超时,请关闭后重新打开并核对数据')); }, 90000);
11
+ pending.set(requestId, {resolve, reject, timer});
12
+ parent.postMessage({channel:'buddy-widget-bridge', render, requestId, method, params}, '*');
13
+ });
14
+ }
15
+ window.addEventListener('message', event => {
16
+ const data = event.data;
17
+ if (event.source !== parent || data?.channel !== 'buddy-widget-reply' || data.render !== render) return;
18
+ const call = pending.get(data.requestId); if (!call) return;
19
+ pending.delete(data.requestId); clearTimeout(call.timer);
20
+ if (data.error) call.reject(Object.assign(new Error(data.error.message), {status:data.error.status, code:data.error.code}));
21
+ else call.resolve(data.value);
22
+ });
23
+ const records = Object.freeze({
24
+ list: module => request('records.list', {module}).then(value => value.records),
25
+ get: (module, recordId) => request('records.get', {module, recordId}).then(value => value.record),
26
+ create: (module, data) => request('records.create', {module, data}),
27
+ update: (module, recordId, data, options = {}) => request('records.update', {module, recordId, data, expectedRevision:options.expectedRevision}),
28
+ delete: (module, recordId, options = {}) => request('records.delete', {module, recordId, expectedRevision:options.expectedRevision}),
29
+ });
30
+ const media = Object.freeze({
31
+ url: mediaId => request('media.url', {mediaId}).then(value => value.url),
32
+ pickAndUpload: () => {
33
+ if (Date.now() - gesture > 1000) return Promise.reject(new Error('请点击按钮后选择图片'));
34
+ gesture = 0; return request('media.pickAndUpload');
35
+ },
36
+ });
37
+ Object.defineProperty(window, 'dadaWidget', { value: Object.freeze({records, media,
38
+ close: async () => { await request('close'); closed = true; },
39
+ }), configurable:false, writable:false });
40
+ window.addEventListener('load', () => parent.postMessage({channel:'buddy-widget-document', render, state:'ready'}, '*'), {once:true});
41
+ })(__WIDGET_RENDER__);
@@ -1,5 +1,4 @@
1
- /* Client reference: my-life-buddies-ios dev ff1845b (2026-09-16).
2
- Native point sizes share the phone's scale; Web content remains in an opaque sandbox. */
1
+ /* PROD Widget 编辑宿主。页面保持不透明沙箱,身份和记录操作由 CLI 代理。 */
3
2
  (() => {
4
3
  const el = (tag, className = '', text) => {
5
4
  const node = document.createElement(tag); node.className = className;
@@ -31,8 +30,8 @@
31
30
  let failed = false, signature = '', selected, focusBefore, openRevision = 0, frameObserver;
32
31
  let collapseTimer, closeTimer, loadTimer, documentFrame, documentToken, documentReady;
33
32
  let phase = 'normal', choices, pill, busy = false, refreshPromise;
34
- let documentLifecycle = false, canDelete = false;
35
- const timers = () => { clearTimeout(loadTimer); frameObserver?.disconnect(); frameObserver = undefined; documentFrame = undefined; documentReady = undefined; };
33
+ let documentLifecycle = false, editing = false, conversationRevision = 0, widgetSession;
34
+ const timers = () => { endSession(); clearTimeout(loadTimer); frameObserver?.disconnect(); frameObserver = undefined; documentFrame = undefined; documentReady = undefined; };
36
35
  const scale = () => device.clientWidth / 415;
37
36
  const resize = new ResizeObserver(() => { phone.style.setProperty('--widget-unit', `${scale()}px`); });
38
37
  resize.observe(device);
@@ -97,16 +96,11 @@
97
96
  const previousIDs = items.map(item => item.id).join(',');
98
97
  items = value.widgets || []; failed = false;
99
98
  documentLifecycle = value.capabilities?.documentLifecycle === true;
100
- canDelete = value.capabilities?.deleteWidget === true;
99
+ editing = value.capabilities?.widgetEditing === true;
100
+ conversationRevision = value.conversationRevision ?? 0;
101
101
  if (selected && !items.some(item => item.id === selected.id)) close(true);
102
102
  if (previousIDs !== items.map(item => item.id).join(',')) { setPhase('normal'); scheduleCollapse(); }
103
- // Revoked ownership must also remove an already open owner menu.
104
- if (selected && items.find(item => item.id === selected.id)?.role !== 'owner') {
105
- overlay.querySelector('.widget-options')?.remove();
106
- overlay.querySelector('.widget-confirm-backdrop')?.remove();
107
- overlay.querySelector('.widget-native-share')?.remove();
108
- const more = overlay.querySelector('.widget-more'); if (more) more.hidden = true;
109
- }
103
+
110
104
  } catch { if (mine === generation) failed = true; }
111
105
  finally { if (mine === generation) { loading = false; lastRefresh = Date.now(); render(); } }
112
106
  }
@@ -115,39 +109,11 @@
115
109
  const note = el('div', `widget-pin-note${error ? ' is-error' : ''}`, text); note.setAttribute('role', 'status'); overlay.append(note);
116
110
  setTimeout(() => note.remove(), 4000);
117
111
  }
118
- function showConfirmation(card, item, mine, current) {
119
- const shade = el('div', 'widget-confirm-backdrop');
120
- const dialog = el('section', 'widget-confirm'); dialog.setAttribute('role', 'alertdialog'); dialog.setAttribute('aria-label', '删除小挂件?');
121
- dialog.append(el('h4', '', '删除小挂件?'), el('p', '', '将删除小挂件和其中的数据,所有会话里的引用及首页固定都会失效。此操作不可恢复。'));
122
- const actions = el('div', 'widget-confirm-actions');
123
- const cancel = button('取消', () => { if (!busy) { shade.remove(); card.querySelector('.widget-more')?.focus(); } });
124
- const remove = button('删除', async () => {
125
- if (busy) return;
126
- busy = true; cancel.disabled = true; remove.disabled = true; remove.textContent = '正在删除…';
127
- try {
128
- await api(`/api/simulator/widgets/${encodeURIComponent(item.id)}`, {method:'DELETE'});
129
- if (mine !== openRevision || current !== generation) return;
130
- items = items.filter(value => value.id !== item.id); render(); busy = false; close();
131
- } catch {
132
- if (mine !== openRevision || current !== generation) return;
133
- shade.remove(); notify('操作失败,请稍后重试', true);
134
- } finally { if (mine === openRevision && current === generation) busy = false; }
135
- }, 'widget-destructive');
136
- actions.append(cancel, remove); dialog.append(actions); shade.append(dialog); card.append(shade); cancel.focus();
137
- }
138
- function showShare(card) {
139
- // Sharing chooses real conversations in the App; the simulator never fabricates recipients.
140
- const notice = el('div', 'widget-native-share');
141
- notice.append(el('h4', '', '在搭搭 App 中分享'), el('p', '', '使用真机预览打开这个搭子,在小挂件的「分享」中选择聊天。'));
142
- notice.append(button('打开真机预览', () => { close(true); document.querySelector('#pair-device').click(); }), button('返回小挂件', () => notice.remove()));
143
- card.append(notice); notice.querySelector('button').focus();
144
- }
145
112
  function ownerMenu(card, item, mine, current) {
146
113
  const old = card.querySelector('.widget-options'); if (old) { old.remove(); return; }
147
114
  const currentItem = items.find(value => value.id === item.id);
148
- if (currentItem?.role !== 'owner' || busy) return;
115
+ if (!currentItem || busy) return;
149
116
  const menu = el('div', 'widget-options'); menu.setAttribute('role', 'menu');
150
- const share = button('分享', () => { menu.remove(); showShare(card); });
151
117
  const pin = button(currentItem.isPinned ? '从首页取消固定' : '固定到首页', async () => {
152
118
  if (busy) return; busy = true; menu.querySelectorAll('button').forEach(node => { node.disabled = true; });
153
119
  try {
@@ -158,10 +124,8 @@
158
124
  if (mine === openRevision && current === generation) { menu.remove(); notify('操作失败,请稍后重试', true); }
159
125
  } finally { if (mine === openRevision && current === generation) busy = false; }
160
126
  });
161
- const remove = button('删除小挂件', () => { menu.remove(); showConfirmation(card, item, mine, current); }, 'widget-destructive');
162
- share.prepend(menuIcon('share')); pin.prepend(menuIcon(currentItem.isPinned ? 'unpin' : 'pin')); remove.prepend(menuIcon('trash'));
163
- for (const row of [share, pin, ...(canDelete ? [remove] : [])]) { row.setAttribute('role', 'menuitem'); menu.append(row); }
164
- card.append(menu); share.focus();
127
+ pin.prepend(menuIcon(currentItem.isPinned ? 'unpin' : 'pin'));
128
+ pin.setAttribute('role', 'menuitem'); menu.append(pin); card.append(menu); pin.focus();
165
129
  }
166
130
  async function open(item, messageId) {
167
131
  if (!item.type || !ready) return;
@@ -191,6 +155,22 @@
191
155
  };
192
156
  const frame = el('iframe', 'widget-document'); frame.title = item.title; frame.setAttribute('sandbox', 'allow-scripts'); frame.referrerPolicy = 'no-referrer'; frame.style.visibility = 'hidden';
193
157
  documentFrame = frame; documentToken = crypto.randomUUID();
158
+ if (editing) {
159
+ const session = {id:item.id, render:documentToken, revision:conversationRevision, closed:false};
160
+ widgetSession = session;
161
+ session.ready = bridgeCall(session, 'open');
162
+ try { await session.ready; }
163
+ catch (error) { if (mine === openRevision && current === generation) { failedLoad(); notify(error.message, true); } return; }
164
+ if (mine !== openRevision || current !== generation || session.closed) return;
165
+ session.heartbeat = setInterval(() => {
166
+ if (session.closed) return;
167
+ bridgeCall(session, 'heartbeat').catch(error => {
168
+ if (session.closed) return;
169
+ if (error.status === 404 || error.status === 409) { close(true); notify('小挂件连接已失效,请重新打开', true); }
170
+ else session.resume = true;
171
+ });
172
+ }, 30000);
173
+ }
194
174
  const url = new URL(window.previewApiUrl?.(`/api/simulator/widgets/${encodeURIComponent(item.id)}`) ?? `/api/simulator/widgets/${encodeURIComponent(item.id)}`, location.href);
195
175
  if (messageId !== undefined) url.searchParams.set('messageId', messageId);
196
176
  url.searchParams.set('render', documentToken); frame.src = url.href;
@@ -210,11 +190,65 @@
210
190
  }); frameObserver.observe(body);
211
191
  loadTimer = setTimeout(failedLoad, 15000);
212
192
  await refresh(true);
213
- if (mine === openRevision && current === generation) more.hidden = failed || items.find(value => value.id === item.id)?.role !== 'owner';
193
+ if (mine === openRevision && current === generation) more.hidden = failed || !items.some(value => value.id === item.id);
194
+ }
195
+ async function bridgeCall(session, method, params = {}, requestId = crypto.randomUUID()) {
196
+ const body = JSON.stringify({render:session.render, conversationRevision:session.revision, method, params, requestId});
197
+ for (let attempt = 0; ; attempt++) {
198
+ try { return await api(`/api/simulator/widgets/${encodeURIComponent(session.id)}/bridge`, {method:'POST', body, ...(method === 'close' ? {keepalive:true} : {})}); }
199
+ catch (error) { if (attempt >= 1 || (error.status && error.status < 500)) throw error; }
200
+ }
214
201
  }
215
- window.addEventListener('message', event => {
216
- if (!documentFrame || event.source !== documentFrame.contentWindow || event.data?.channel !== 'buddy-widget-document' || event.data?.render !== documentToken) return;
217
- documentReady?.(event.data.state);
202
+ function endSession() {
203
+ const session = widgetSession; widgetSession = undefined;
204
+ if (!session || session.closed) return;
205
+ session.closed = true; clearInterval(session.heartbeat); session.cancelPicker?.();
206
+ // 等正在建立的交互完成;CLI 会等待已发出的保存,再幂等关闭。
207
+ void session.ready.then(() => bridgeCall(session, 'close')).catch(() => {});
208
+ }
209
+ function pickImage(session) {
210
+ if (session.cancelPicker) return Promise.reject(new Error('请先完成当前图片选择'));
211
+ return new Promise((resolve, reject) => {
212
+ const input = el('input'); input.type = 'file'; input.accept = 'image/jpeg,image/png,image/webp'; input.hidden = true;
213
+ const finish = (error, value) => { session.cancelPicker = undefined; input.remove(); error ? reject(error) : resolve(value); };
214
+ session.cancelPicker = () => finish(new Error('图片选择已取消'));
215
+ input.addEventListener('cancel', () => finish(new Error('图片选择已取消')));
216
+ input.addEventListener('change', async () => {
217
+ try {
218
+ const file = input.files?.[0];
219
+ if (!file) throw new Error('图片选择已取消');
220
+ if (!['image/jpeg','image/png','image/webp'].includes(file.type) || file.size > 5 * 1024 * 1024) throw new Error('请选择 5 MiB 以内的 JPEG、PNG 或 WebP 图片');
221
+ const base64 = await new Promise((done, fail) => {
222
+ const reader = new FileReader(); reader.onload = () => done(String(reader.result).split(',')[1]); reader.onerror = () => fail(new Error('无法读取图片')); reader.readAsDataURL(file);
223
+ });
224
+ if (session.closed) throw new Error('小挂件已关闭');
225
+ finish(null, await bridgeCall(session, 'media.upload', {mimeType:file.type, base64}));
226
+ } catch (error) { finish(error); }
227
+ });
228
+ overlay.append(input); input.click();
229
+ });
230
+ }
231
+ window.addEventListener('message', async event => {
232
+ if (!documentFrame || event.source !== documentFrame.contentWindow || event.data?.render !== documentToken) return;
233
+ if (event.data.channel === 'buddy-widget-document') { documentReady?.(event.data.state); return; }
234
+ if (event.data.channel !== 'buddy-widget-bridge') return;
235
+ const {method, params, requestId} = event.data, session = widgetSession;
236
+ const target = event.source, render = documentToken;
237
+ const reply = value => target.postMessage({channel:'buddy-widget-reply', render, requestId, ...value}, '*');
238
+ try {
239
+ if (!session || session.closed) throw new Error('当前页面不支持编辑,请重新打开小挂件');
240
+ if (typeof requestId !== 'string' || !/^[a-zA-Z0-9-]{16,64}$/.test(requestId)
241
+ || !['records.list','records.get','records.create','records.update','records.delete','media.url','media.pickAndUpload','close'].includes(method)) throw new Error('页面请求了未开放的能力');
242
+ await session.ready;
243
+ if (session.closed) throw new Error('小挂件已关闭');
244
+ if (session.resume) { await bridgeCall(session, 'resume'); session.resume = false; }
245
+ const value = method === 'media.pickAndUpload' ? await pickImage(session) : await bridgeCall(session, method, params, requestId);
246
+ if (session !== widgetSession || session.closed) return;
247
+ reply({value});
248
+ if (method === 'close') close();
249
+ } catch (error) {
250
+ reply({error:{message:error.message || '操作未完成,请重试', status:error.status, code:error.code}});
251
+ }
218
252
  });
219
253
  overlay.addEventListener('click', event => { if (event.target === overlay) close(); });
220
254
  document.addEventListener('pointerdown', event => {
@@ -225,11 +259,11 @@
225
259
  document.addEventListener('keydown', event => {
226
260
  if (event.key === 'Escape') {
227
261
  if (busy) return;
228
- const nested = overlay.querySelector('.widget-confirm-backdrop,.widget-native-share,.widget-options');
262
+ const nested = overlay.querySelector('.widget-options');
229
263
  if (nested) { nested.remove(); overlay.querySelector('.widget-more')?.focus(); } else if (!overlay.hidden) close(); else closeChoices();
230
264
  }
231
265
  if (event.key === 'Tab' && !overlay.hidden) {
232
- const scope = overlay.querySelector('.widget-confirm,.widget-native-share') || overlay;
266
+ const scope = overlay;
233
267
  const controls = [...scope.querySelectorAll('button:not(:disabled), iframe')].filter(node => !node.hidden && node.getClientRects().length);
234
268
  const first = controls[0], last = controls.at(-1);
235
269
  if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
@@ -240,7 +274,7 @@
240
274
  clearTimeout(collapseTimer); closeChoices(false);
241
275
  if (!document.hidden) { setPhase('normal'); scheduleCollapse(); void refresh(true); }
242
276
  });
243
- window.addEventListener('pagehide', () => { resize.disconnect(); timers(); clearTimeout(collapseTimer); clearTimeout(closeTimer); });
277
+ window.addEventListener('pagehide', () => { resize.disconnect(); close(true); clearTimeout(collapseTimer); clearTimeout(closeTimer); });
244
278
  return {
245
279
  update({active, conversationRevision, devRevision, reloading = false}) {
246
280
  const key = `${conversationRevision || 0}:${devRevision || 0}`;
@@ -248,7 +282,7 @@
248
282
  else if (selected && (key !== scope || reloading)) reopen = { id: selected.id, messageId: selected.messageId };
249
283
  conversationScope = conversationRevision;
250
284
  if (key !== scope || !active) {
251
- scope = key; generation++; ready = false; items = []; failed = false; loading = false; lastRefresh = 0; busy = false; documentLifecycle = false; canDelete = false;
285
+ scope = key; generation++; ready = false; items = []; failed = false; loading = false; lastRefresh = 0; busy = false; documentLifecycle = false; editing = false;
252
286
  clearTimeout(collapseTimer); close(true);
253
287
  }
254
288
  ready = active; render();
@@ -271,7 +305,7 @@
271
305
  }
272
306
  details.append(el('p', item.type && item.summary?.trim() ? '' : 'widget-secondary', item.type ? item.summary?.trim() || '打开查看最新内容' : '此小挂件已删除或不可用')); card.append(details);
273
307
  if (item.type) card.append(button('去查看', () => open(item, messageId), 'widget-entry-open'));
274
- const permission = {owner:'可管理、分享',editor:'可编辑',viewer:'仅支持查看'}[item.role];
308
+ const permission = item.role ? '会话成员可编辑、固定' : undefined;
275
309
  const metadata = el('div', 'widget-entry-metadata');
276
310
  if (item.creatorName?.trim()) metadata.append(el('span', 'widget-creator', `${item.creatorName.trim()}创建`));
277
311
  if (permission) metadata.append(el('span', 'widget-permission', (metadata.children.length ? '|' : '') + permission));
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@my-life-buddies/cli",
3
- "version": "0.23.0-dev.1",
3
+ "version": "0.23.0-rc.0",
4
4
  "type": "module",
5
- "description": "搭搭 SIT 开发命令行工具",
5
+ "description": "搭搭 PROD 内测命令行工具",
6
6
  "publishConfig": {
7
7
  "access": "public",
8
8
  "registry": "https://registry.npmjs.org/",
9
- "tag": "dev"
9
+ "tag": "prod"
10
10
  },
11
11
  "engines": {
12
12
  "node": ">=24"
@@ -15,7 +15,7 @@
15
15
  "darwin"
16
16
  ],
17
17
  "bin": {
18
- "buddy-cli-sit": "dist/bin/buddy.js"
18
+ "buddy-cli-prod": "dist/bin/buddy.js"
19
19
  },
20
20
  "files": [
21
21
  "dist/bin",
@@ -41,7 +41,7 @@
41
41
  "ws": "8.21.3"
42
42
  },
43
43
  "devDependencies": {
44
- "@buddy/cli-core": "0.23.0-dev.1",
45
- "@buddy/preview": "0.23.0-dev.1"
44
+ "@buddy/cli-core": "0.23.0-rc.0",
45
+ "@buddy/preview": "0.23.0-rc.0"
46
46
  }
47
47
  }