dsh-contextos-devops 0.0.0-stage → 1.48.31

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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -2
  3. package/assets/avatar.jpg +0 -0
  4. package/assets/client.css +3316 -0
  5. package/assets/icon-180.png +0 -0
  6. package/build/client.mjs +64 -0
  7. package/client.js +7622 -0
  8. package/cordis.patch.yml +3 -0
  9. package/icon.svg +15 -0
  10. package/index.js +345 -0
  11. package/lib/base/env.js +22 -0
  12. package/lib/base/field.js +25 -0
  13. package/lib/base/http.js +140 -0
  14. package/lib/base/platform.js +33 -0
  15. package/lib/base/registry.js +186 -0
  16. package/lib/base/state.js +66 -0
  17. package/lib/base/table.js +47 -0
  18. package/lib/base/texttable.js +26 -0
  19. package/lib/base/url.js +178 -0
  20. package/lib/core/README.md +17 -0
  21. package/lib/core/apps.js +675 -0
  22. package/lib/core/auth.js +110 -0
  23. package/lib/core/avatar.js +289 -0
  24. package/lib/core/backup.js +105 -0
  25. package/lib/core/codegen.js +401 -0
  26. package/lib/core/copilot.js +419 -0
  27. package/lib/core/data.js +157 -0
  28. package/lib/core/device.js +148 -0
  29. package/lib/core/files.js +152 -0
  30. package/lib/core/gobuild.js +63 -0
  31. package/lib/core/iam.js +122 -0
  32. package/lib/core/identity.js +22 -0
  33. package/lib/core/nodes.js +627 -0
  34. package/lib/core/ops.js +306 -0
  35. package/lib/core/project.js +393 -0
  36. package/lib/core/spaces.js +406 -0
  37. package/lib/core/terminal.js +137 -0
  38. package/lib/core/terminal2.js +141 -0
  39. package/lib/core/users.js +51 -0
  40. package/lib/core/vocab.js +158 -0
  41. package/lib/misc/async.js +15 -0
  42. package/lib/misc/shell.js +27 -0
  43. package/lib/misc/static.js +33 -0
  44. package/locale/en.json +10 -0
  45. package/locale/zh.json +10 -0
  46. package/package.json +91 -4
  47. package/src/client/README.md +37 -0
  48. package/src/client/parts/00-prelude.js +315 -0
  49. package/src/client/parts/05-bootstrap.js +61 -0
  50. package/src/client/parts/08-store.js +85 -0
  51. package/src/client/parts/10-ui.js +188 -0
  52. package/src/client/parts/12-debug.js +361 -0
  53. package/src/client/parts/15-layout.js +86 -0
  54. package/src/client/parts/20-devworkspace.js +789 -0
  55. package/src/client/parts/25-editor-ui.js +28 -0
  56. package/src/client/parts/30-modals.js +85 -0
  57. package/src/client/parts/32-cards.js +176 -0
  58. package/src/client/parts/34-forms.js +176 -0
  59. package/src/client/parts/36-data.js +267 -0
  60. package/src/client/parts/40-project.js +430 -0
  61. package/src/client/parts/45-map.js +109 -0
  62. package/src/client/parts/46-pm.js +158 -0
  63. package/src/client/parts/47-three.js +324 -0
  64. package/src/client/parts/49-msg.js +167 -0
  65. package/src/client/parts/50-chat.js +1422 -0
  66. package/src/client/parts/51-pmselection.js +31 -0
  67. package/src/client/parts/52-pmdata.js +29 -0
  68. package/src/client/parts/55-pm.js +146 -0
  69. package/src/client/parts/58-config.js +381 -0
  70. package/src/client/parts/59-monitor.js +262 -0
  71. package/src/client/parts/60-page.js +1518 -0
  72. package/src/client/styles/kit.css +20 -0
  73. package/src/client/styles/main.css +3294 -0
  74. package/src/client/styles/tokens.css +2 -0
  75. package/vendor/leaflet/leaflet.css +661 -0
  76. package/vendor/leaflet/leaflet.js +6 -0
  77. package/vendor/three/three.module.js +53044 -0
  78. package/vendor/xterm/addon-fit.js +2 -0
  79. package/vendor/xterm/xterm.css +218 -0
  80. package/vendor/xterm/xterm.js +2 -0
@@ -0,0 +1,110 @@
1
+ // 远程 / 容器节点的访问凭证。
2
+ //
3
+ // 本机节点(127.0.0.1:<port>)免登录、等同 root,不需要凭证;
4
+ // 远程节点与容器节点必须拿到 token 或 sessid 才能调用它的接口:
5
+ // PUT /c/web.token/action/create → token
6
+ // PUT /c/aaa.sess/action/create/<username> → sessid
7
+ // 两者都缓存在 state 目录,过期后自动重新获取。
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { stateDir } from '../base/env.js';
11
+ import { http, encode } from '../base/http.js';
12
+
13
+ const TTL = 30 * 60 * 1000;
14
+ // 平台级 sessid 的 cookie 值:space <pod> <command> check <sessid>
15
+ export const APP_COOKIE = new Map();
16
+ const FILE = () => path.join(stateDir(), 'node-auth.json');
17
+
18
+ function readAll() { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')) || {}; } catch { return {}; } }
19
+ function writeAll(v) { try { fs.mkdirSync(stateDir(), { recursive: true }); fs.writeFileSync(FILE(), JSON.stringify(v, null, 2)); } catch {} }
20
+
21
+ /**
22
+ * 工具令牌:web.token create(可简写 token)。
23
+ * 用途:第三方工具、git 推送、服务节点之间的互连认证。
24
+ * 它代表「一个工具/节点」,不解析成某个用户。
25
+ */
26
+ export async function toolToken({ node }) {
27
+ for (const path of ['/c/web.token/action/create', '/c/token/action/create']) {
28
+ const r = await http(node, path, { method: 'PUT', timeout: 10000, auth: false });
29
+ const token = String(r.text || '').trim();
30
+ if (r.status === 200 && /^[0-9a-f]{16,}$/i.test(token)) return token;
31
+ }
32
+ throw new Error('web.token create failed');
33
+ }
34
+
35
+ /** 兼容旧名 */
36
+ export const nodeToken = toolToken;
37
+
38
+ /**
39
+ * 管理员 sessid:aaa.sess create <username>。
40
+ * 用途:系统管理员登录网页,可以执行更多系统级操作。
41
+ */
42
+ export async function adminSessid({ node, username }) {
43
+ const who = String(username || '').trim();
44
+ if (!who) throw new Error('admin sessid needs a username');
45
+ const r = await http(node, `/c/aaa.sess/action/create/${encode(who)}`, { method: 'PUT', timeout: 10000, auth: false });
46
+ const sessid = String(r.text || '').trim();
47
+ if (r.status !== 200 || !/^[0-9a-f]{16,}$/i.test(sessid)) throw new Error(`aaa.sess create failed: HTTP ${r.status}`);
48
+ return sessid;
49
+ }
50
+
51
+
52
+ /**
53
+ * 应用级 sessid:云社区的会话命令
54
+ * PUT /s/<space>/c/web.team.gonganxitong.sess/action/create/user_uid/<uid>
55
+ * 回执里 result[0](或 _arg[0]==='sessid' 时的值)就是 sessid;
56
+ * _process=['_cookie'] 表示它同时会被写进 cookie。
57
+ */
58
+ export async function appSession({ node, space = '20240724-community', userUid }) {
59
+ const sessid = await appSessid({ node, space, userUid });
60
+ return { sessid, cookie: APP_COOKIE.get(Number(node)) || `space ${space} web.team.gonganxitong.sess check ${sessid}` };
61
+ }
62
+
63
+ export async function appSessid({ node, space = '20240724-community', userUid }) {
64
+ const uid = String(userUid || '').trim();
65
+ if (!uid) throw new Error('app sessid needs user_uid');
66
+ const q = `?user.uid=${encode(uid)}&user_uid=${encode(uid)}`;
67
+ const r = await http(node, `/s/${encode(space)}/c/web.team.gonganxitong.sess/action/create/user_uid/${encode(uid)}${q}`, { method: 'PUT', timeout: 12000, auth: false });
68
+ let sid = '', cookie = '';
69
+ try {
70
+ const env = JSON.parse(r.text);
71
+ sid = (env.result && env.result[0]) || '';
72
+ if (!sid && Array.isArray(env._arg) && env._arg[0] === 'sessid') sid = env._arg[1] || '';
73
+ // _arg[1] 是完整 cookie 值:space <pod> <command> check <sessid>
74
+ if (Array.isArray(env._arg) && env._arg[0] === 'sessid' && /\scheck\s/.test(String(env._arg[1] || ''))) cookie = String(env._arg[1]);
75
+ } catch { sid = String(r.text || '').trim(); }
76
+ if (!cookie && sid) cookie = `space ${space} web.team.gonganxitong.sess check ${sid}`;
77
+ try { APP_COOKIE.set(Number(node), cookie); } catch {}
78
+ if (r.status !== 200 || !/^[0-9a-f]{16,}$/i.test(String(sid))) throw new Error(`app sess create failed: HTTP ${r.status} ${String(r.text).slice(0, 120)}`);
79
+ return String(sid);
80
+ }
81
+
82
+ /** 读取缓存凭证(不主动获取)。 */
83
+ export function peekNodeAuth({ node }) {
84
+ const all = readAll();
85
+ const row = all[String(node)] || null;
86
+ if (!row) return null;
87
+ const fresh = Date.now() - Number(row.at || 0) < TTL;
88
+ return { ...row, fresh };
89
+ }
90
+
91
+ /** 获取(必要时刷新)凭证。本机节点返回 { local: true },不取凭证。 */
92
+ export async function ensureNodeAuth({ node, username = '', force = false } = {}) {
93
+ const cached = peekNodeAuth({ node });
94
+ if (!force && cached && cached.fresh) return cached;
95
+ if (cached && cached.fresh === false && cached.sessid) return cached; // 过期也先复用,失败了再换
96
+ try {
97
+ const token = await toolToken({ node });
98
+ const row = { node: Number(node), token, sessid: cached?.sessid || '', username: cached?.username || username, at: Date.now() };
99
+ const all = readAll(); all[String(node)] = row; writeAll(all);
100
+ return row;
101
+ } catch (e) {
102
+ if (!username) throw e;
103
+ const sessid = await adminSessid({ node, username });
104
+ const row = { node: Number(node), token: cached?.token || '', sessid, username, at: Date.now() };
105
+ const all = readAll(); all[String(node)] = row; writeAll(all);
106
+ return row;
107
+ }
108
+ }
109
+
110
+ export const AUTH_TTL = TTL;
@@ -0,0 +1,289 @@
1
+ // 核心业务:数字分身 / 背景 / 用户媒体。
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { stateDir } from '../base/env.js';
5
+ import { http, encode } from '../base/http.js';
6
+ import { listNodes } from '../base/registry.js';
7
+ import { buildSpace, listSpaces, warmSpace } from './spaces.js';
8
+ import { createInstance } from './apps.js';
9
+ import { ensureUser } from './users.js';
10
+
11
+ export function avatarsFile() {
12
+ return path.join(stateDir(), 'avatars.json');
13
+ }
14
+
15
+ export async function readAvatars() {
16
+ try { return JSON.parse(await fs.readFile(avatarsFile(), 'utf8')) || {}; } catch { return {}; }
17
+ }
18
+
19
+ export async function writeAvatars(state) {
20
+ await fs.mkdir(stateDir(), { recursive: true });
21
+ await fs.writeFile(avatarsFile(), JSON.stringify(state, null, 2));
22
+ }
23
+
24
+ export async function defaultAvatar(node) {
25
+ const uid = await ensureUser(node);
26
+ return {
27
+ id: uid,
28
+ uid,
29
+ username: `dsh_copilot_${node}`,
30
+ name: '我的分身',
31
+ city: '深圳市',
32
+ company: '云轩领航',
33
+ persona: '专业、稳重、直接',
34
+ preferences: '技术、产品、效率',
35
+ style: '简洁、结构化',
36
+ bio: '',
37
+ created: new Date().toISOString(),
38
+ };
39
+ }
40
+
41
+ export async function listAvatars({ node } = {}) {
42
+ const port = String(Number(node));
43
+ const state = await readAvatars();
44
+ let bucket = state[port];
45
+ if (!bucket || !Array.isArray(bucket.items) || !bucket.items.length) {
46
+ const first = await defaultAvatar(node);
47
+ bucket = { active: first.uid, items: [first] };
48
+ state[port] = bucket;
49
+ await writeAvatars(state);
50
+ }
51
+ let changed = false;
52
+ for (const item of bucket.items) {
53
+ if (!item.persona) { item.persona = '专业、稳重、直接'; changed = true; }
54
+ if (!item.preferences) { item.preferences = '技术、产品、效率'; changed = true; }
55
+ if (!item.style) { item.style = '简洁、结构化'; changed = true; }
56
+ }
57
+ const activeItem = bucket.items.find((x) => x.uid === bucket.active) || bucket.items[0];
58
+ if (activeItem && (!activeItem.project || !activeItem.project.place_uid) && !activeItem.project?.error) {
59
+ try {
60
+ activeItem.project = await createProjectForAvatar(node, activeItem);
61
+ } catch (e) {
62
+ activeItem.project = { error: String(e?.message || e) };
63
+ }
64
+ changed = true;
65
+ }
66
+ if (changed) await writeAvatars(state);
67
+ return { active: bucket.active || bucket.items[0]?.uid || '', items: bucket.items };
68
+ }
69
+
70
+ export async function createProjectForAvatar(node, avatar) {
71
+ const spaces = await listSpaces({ node });
72
+ const space = spaces.find((s) => s.name === '20240724-enterprise')
73
+ || spaces.find((s) => s.name.endsWith('-enterprise'))
74
+ || spaces.find((s) => s.status === 'start');
75
+ if (!space) return { error: 'no enterprise/space available' };
76
+ await warmSpace({ node, space: space.name });
77
+ const create = () => createInstance({
78
+ node,
79
+ space: space.name,
80
+ command: 'web.team.xiangmuguanli.portal',
81
+ place: 'project_name',
82
+ name: `${avatar.name} 的项目管理`,
83
+ city: avatar.city || '深圳市',
84
+ company: avatar.company || '云轩领航',
85
+ uid: avatar.uid,
86
+ });
87
+ let r = await create();
88
+ if (!r?.place_uid) {
89
+ // The space process may still run the binary it booted with, in which case
90
+ // placeCreate is a silent no-op ("not found index"). Rebuild restarts it
91
+ // with the freshly compiled binary before retrying once.
92
+ let rebuild = '';
93
+ try { await buildSpace({ node, space: space.name }); } catch (e) { rebuild = String(e?.message || e); }
94
+ r = await create();
95
+ if (!r?.place_uid) throw new Error(`project instance has no place_uid${rebuild ? ' (rebuild failed: ' + rebuild + ')' : ''}: ${r?.hint || ''}`);
96
+ }
97
+ return { space: r.space, command: r.command, place_uid: r.place_uid, name: r.name, created: true };
98
+ }
99
+
100
+ /** Ensure an avatar has a usable PM instance; used by init and DSH tools. */
101
+ export async function ensureProject({ node, avatarUid = '' } = {}) {
102
+ const port = String(Number(node));
103
+ const state = await readAvatars();
104
+ const bucket = state[port];
105
+ const item = bucket?.items?.find((x) => x.uid === (avatarUid || bucket.active));
106
+ if (!item) throw new Error('avatar not found');
107
+ if (item.project?.place_uid) return item.project;
108
+ item.project = await createProjectForAvatar(node, item);
109
+ await writeAvatars(state);
110
+ return item.project;
111
+ }
112
+
113
+ export async function createAvatar({ node, name = '', city = '深圳市', company = '云轩领航', persona = '', preferences = '', style = '', bio = '' } = {}) {
114
+ const port = String(Number(node));
115
+ const state = await readAvatars();
116
+ const bucket = state[port] || (state[port] = { active: '', items: [] });
117
+ const username = `dsh_avatar_${port}_${Date.now().toString(36)}`;
118
+ const r = await http(node, `/c/aaa.user/action/create/userrole/tech/username/${encode(username)}/usernick/${encode(name || '我的分身')}`, { method: 'PUT', timeout: 15000 });
119
+ if (r.status !== 200) throw new Error(`create avatar user failed: HTTP ${r.status}`);
120
+ const uid = r.text.trim();
121
+ if (!/^[0-9a-f]{32}$/i.test(uid)) throw new Error(`create avatar returned no uid: ${r.text.slice(0, 120)}`);
122
+ const item = {
123
+ id: uid, uid, username,
124
+ name: name || `分身 ${bucket.items.length + 1}`,
125
+ city: city || '深圳市', company: company || '云轩领航',
126
+ persona: persona || '专业、亲和',
127
+ preferences: preferences || '', style: style || '简洁、直接', bio: bio || '',
128
+ created: new Date().toISOString(),
129
+ };
130
+ bucket.items.push(item);
131
+ if (!bucket.active) bucket.active = uid;
132
+ state[port] = bucket;
133
+ await writeAvatars(state);
134
+ try {
135
+ item.project = await createProjectForAvatar(node, item);
136
+ await writeAvatars(state);
137
+ } catch (e) {
138
+ item.project = { error: String(e?.message || e) };
139
+ await writeAvatars(state);
140
+ }
141
+ return item;
142
+ }
143
+
144
+ export async function setAvatar({ node, uid } = {}) {
145
+ const port = String(Number(node));
146
+ const state = await readAvatars();
147
+ const bucket = state[port];
148
+ if (!bucket || !bucket.items.some((x) => x.uid === uid)) throw new Error('avatar not found');
149
+ bucket.active = uid;
150
+ await writeAvatars(state);
151
+ return bucket;
152
+ }
153
+
154
+ export async function updateAvatar({ node, uid, patch = {} } = {}) {
155
+ const port = String(Number(node));
156
+ const state = await readAvatars();
157
+ const bucket = state[port];
158
+ const item = bucket?.items?.find((x) => x.uid === uid);
159
+ if (!item) throw new Error('avatar not found');
160
+ for (const key of ['name', 'city', 'company', 'persona', 'preferences', 'style', 'bio', 'image']) {
161
+ if (typeof patch[key] === 'string' && patch[key].trim()) item[key] = patch[key].trim();
162
+ }
163
+ if (Array.isArray(patch.skills)) item.skills = patch.skills.filter((x) => typeof x === 'string' && x.trim()).slice(0, 20);
164
+ await writeAvatars(state);
165
+ return item;
166
+ }
167
+
168
+ export async function deleteAvatar({ node, uid } = {}) {
169
+ const port = String(Number(node));
170
+ const state = await readAvatars();
171
+ const bucket = state[port];
172
+ if (!bucket) return { deleted: false };
173
+ bucket.items = bucket.items.filter((x) => x.uid !== uid);
174
+ if (!bucket.items.length) {
175
+ const first = await defaultAvatar(node);
176
+ bucket.items = [first];
177
+ bucket.active = first.uid;
178
+ } else if (bucket.active === uid) {
179
+ bucket.active = bucket.items[0].uid;
180
+ }
181
+ await writeAvatars(state);
182
+ return bucket;
183
+ }
184
+
185
+ /* ── 我的背景:每个用户可以建任意多个背景,每个背景可上传一张照片作为赛博世界 ── */
186
+ export function backgroundsFile() {
187
+ return path.join(stateDir(), 'backgrounds.json');
188
+ }
189
+ export async function readBackgrounds() {
190
+ try { return JSON.parse(await fs.readFile(backgroundsFile(), 'utf8')) || {}; } catch { return {}; }
191
+ }
192
+ export async function writeBackgrounds(state) {
193
+ await fs.mkdir(stateDir(), { recursive: true });
194
+ await fs.writeFile(backgroundsFile(), JSON.stringify(state, null, 2));
195
+ }
196
+ const DEFAULT_BACKGROUND = { id: 'default', name: '默认赛博世界', image: '', builtin: true };
197
+
198
+ export async function listBackgrounds({ node } = {}) {
199
+ const port = String(Number(node));
200
+ const state = await readBackgrounds();
201
+ let bucket = state[port];
202
+ if (!bucket || !Array.isArray(bucket.items) || !bucket.items.length) {
203
+ bucket = { active: DEFAULT_BACKGROUND.id, items: [{ ...DEFAULT_BACKGROUND }] };
204
+ state[port] = bucket;
205
+ await writeBackgrounds(state);
206
+ }
207
+ return { active: bucket.active || bucket.items[0].id, items: bucket.items };
208
+ }
209
+ export async function createBackground({ node, name = '' } = {}) {
210
+ const port = String(Number(node));
211
+ const state = await readBackgrounds();
212
+ const bucket = state[port] || (state[port] = { active: '', items: [] });
213
+ const item = { id: `bg_${Date.now().toString(36)}`, name: name || `背景 ${bucket.items.length + 1}`, image: '', params: {}, created: new Date().toISOString() };
214
+ bucket.items.push(item);
215
+ bucket.active = item.id;
216
+ await writeBackgrounds(state);
217
+ return { active: bucket.active, items: bucket.items, created: item };
218
+ }
219
+ export async function setBackground({ node, id } = {}) {
220
+ const port = String(Number(node));
221
+ const state = await readBackgrounds();
222
+ const bucket = state[port];
223
+ if (!bucket || !bucket.items.some((x) => x.id === id)) throw new Error('background not found');
224
+ bucket.active = id;
225
+ await writeBackgrounds(state);
226
+ return bucket;
227
+ }
228
+ export async function updateBackground({ node, id, patch = {} } = {}) {
229
+ const port = String(Number(node));
230
+ const state = await readBackgrounds();
231
+ const bucket = state[port];
232
+ const item = bucket?.items?.find((x) => x.id === id);
233
+ if (!item) throw new Error('background not found');
234
+ for (const k of ['name', 'image']) if (patch[k] !== undefined) item[k] = patch[k];
235
+ if (patch.params && typeof patch.params === 'object') {
236
+ const p = item.params || (item.params = {});
237
+ for (const k of ['theme', 'lang', 'timezone', 'location']) {
238
+ if (patch.params[k] !== undefined) p[k] = String(patch.params[k] || '');
239
+ }
240
+ }
241
+ await writeBackgrounds(state);
242
+ return bucket;
243
+ }
244
+ export async function deleteBackground({ node, id } = {}) {
245
+ const port = String(Number(node));
246
+ const state = await readBackgrounds();
247
+ const bucket = state[port];
248
+ if (!bucket) return { deleted: false };
249
+ bucket.items = bucket.items.filter((x) => x.id !== id);
250
+ if (!bucket.items.length) bucket.items = [{ ...DEFAULT_BACKGROUND }];
251
+ if (bucket.active === id || !bucket.items.some((x) => x.id === bucket.active)) bucket.active = bucket.items[0].id;
252
+ await writeBackgrounds(state);
253
+ return bucket;
254
+ }
255
+
256
+
257
+ /** 把头像/背景图落到节点磁盘,并写入 web.team.gonganxitong.user(avatar/background 列)。 */
258
+ export async function saveUserMedia({ node, uid = '', kind = 'avatar', dataUrl = '', name = '' } = {}) {
259
+ if (!uid) throw new Error('uid is required');
260
+ const m = /^data:image\/([a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)$/.exec(String(dataUrl || ''));
261
+ if (!m) throw new Error('invalid image data url');
262
+ const ext = m[1] === 'jpeg' ? 'jpg' : m[1].replace(/[^a-zA-Z0-9]/g, '') || 'jpg';
263
+ const nodes = await listNodes();
264
+ const n = nodes.find((x) => Number(x.port) === Number(node));
265
+ if (!n?.dir) throw new Error('node not found');
266
+ const dir = path.join(n.dir, 'var', 'media');
267
+ await fs.mkdir(dir, { recursive: true });
268
+ const file = `${uid}-${kind}.${ext}`;
269
+ await fs.writeFile(path.join(dir, file), Buffer.from(m[2].replace(/\s/g, ''), 'base64'));
270
+ const rel = `var/media/${file}`;
271
+ const field = kind === 'background' ? 'background' : 'avatar';
272
+ let updated = false;
273
+ try {
274
+ const spaces = await listSpaces({ node });
275
+ const space = spaces.find((x) => /-community$/.test(x.name)) || spaces[0];
276
+ if (space?.name) {
277
+ const base = `/s/${space.name}/c/web.team.gonganxitong.user/action`;
278
+ // upsert 档案行(open_id = 分身 uid),再补 background/avatar
279
+ await http(node, `${base}/create?open_id=${encode(uid)}&avatar=${encode(rel)}&usernick=${encode(name || '我的数字分身')}`, { method: 'PUT', timeout: 12000 }).catch(() => null);
280
+ const r = await http(node, `${base}/modify?user_uid=${encode(uid)}&name=${encode(name || '我的数字分身')}&${field}=${encode(rel)}`, { method: 'POST', timeout: 12000 }).catch(() => null);
281
+ updated = !!r && r.status === 200;
282
+ }
283
+ } catch { /* 平台不可用时只落盘 */ }
284
+ return { path: rel, url: `http://127.0.0.1:${node}/p/${rel}`, updated };
285
+ }
286
+
287
+ /** One persistent DSH user per node; created on first request and reused. */
288
+ // 节点台账与 macOS 客户端共用一份:~/.contextos/nodes.json(数组格式,字段对齐
289
+ // CtxNodeRegistry 的 dictionaryValue:dir/name/port/parent/platform/source/container/image/resident/env)。
@@ -0,0 +1,105 @@
1
+ // 数据备份与监控采样:内核还没有这套能力,插件侧先做「能救命」的部分。
2
+ import path from 'node:path';
3
+ import fsp from 'node:fs';
4
+ import os from 'node:os';
5
+ import { listNodes } from '../base/registry.js';
6
+ import { probe } from '../base/http.js';
7
+ import { spaceBase } from './spaces.js';
8
+
9
+ export function backupRoot() {
10
+ return path.join(os.homedir(), '.contextos', 'backups');
11
+ }
12
+
13
+ function stamp() {
14
+ const d = new Date();
15
+ const p = (v) => String(v).padStart(2, '0');
16
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
17
+ }
18
+
19
+ /** 备份一个空间的数据目录(var/db),写 manifest,返回路径与文件清单。 */
20
+ export async function backupSpace({ node, space, tag = '' } = {}) {
21
+ if (!space) throw new Error('backupSpace needs a space');
22
+ const n = (await listNodes()).find((x) => x.port === Number(node));
23
+ if (!n) throw new Error(`node ${node} not found`);
24
+ const base = spaceBase ? path.join(n.dir, 'usr', 'local', 'work', space) : '';
25
+ const dbDir = path.join(base, 'var', 'db');
26
+ const out = path.join(backupRoot(), `${node}-${space}${tag ? '-' + tag : ''}-${stamp()}`);
27
+ await fsp.promises.mkdir(out, { recursive: true });
28
+ const files = [];
29
+ try {
30
+ for (const f of await fsp.promises.readdir(dbDir)) {
31
+ if (!f.endsWith('.db')) continue;
32
+ await fsp.promises.copyFile(path.join(dbDir, f), path.join(out, f));
33
+ const st = await fsp.promises.stat(path.join(out, f));
34
+ files.push({ name: f, size: st.size });
35
+ }
36
+ } catch (e) {
37
+ throw new Error(`备份失败:${dbDir} 不可读(${e.message})`);
38
+ }
39
+ const manifest = { node: Number(node), space, dir: base, at: new Date().toISOString(), files };
40
+ await fsp.promises.writeFile(path.join(out, 'manifest.json'), JSON.stringify(manifest, null, 2));
41
+ return { ...manifest, backupDir: out, count: files.length, size: files.reduce((a, b) => a + b.size, 0) };
42
+ }
43
+
44
+ /** 列出已有备份(按时间倒序)。 */
45
+ export async function listBackups({ limit = 20 } = {}) {
46
+ const root = backupRoot();
47
+ let dirs = [];
48
+ try { dirs = await fsp.promises.readdir(root); } catch { return []; }
49
+ const out = [];
50
+ for (const d of dirs.sort().reverse().slice(0, limit)) {
51
+ try {
52
+ const m = JSON.parse(await fsp.promises.readFile(path.join(root, d, 'manifest.json'), 'utf8'));
53
+ out.push({ ...m, backupDir: path.join(root, d) });
54
+ } catch { /* 跳过不完整目录 */ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** 节点健康采样:一次拿到端口、存活、版本、耗时。 */
60
+ export async function sampleNodes() {
61
+ const nodes = await listNodes();
62
+ const rows = [];
63
+ for (const n of nodes) {
64
+ const started = Date.now();
65
+ let ok = false, version = '';
66
+ try { const p = await probe(n.port); ok = !!p.live; version = p.version || ''; } catch { /* offline */ }
67
+ rows.push({ port: n.port, name: n.name, ok, version, ms: Date.now() - started });
68
+ }
69
+ return { at: new Date().toISOString(), nodes: rows, up: rows.filter((r) => r.ok).length, total: rows.length };
70
+ }
71
+
72
+ import { http, encode, envelopeShape } from '../base/http.js';
73
+ import { methodOf } from '../base/url.js';
74
+ import { parseFixedTable } from '../base/table.js';
75
+
76
+ /** 监控面板的统一取数入口:节点/空间 + 命令 + 参数。 */
77
+ export async function monitorQuery({ node, space = '', command, action = '', params = {} } = {}) {
78
+ if (!command) throw new Error('monitorQuery needs a command');
79
+ const qs = new URLSearchParams();
80
+ for (const [k, v] of Object.entries(params || {})) if (v !== '' && v != null) qs.set(k, v);
81
+ const base = space ? `/s/${encode(space)}/c/${command}` : `/c/${command}`;
82
+ const path = `${base}${action ? `/action/${action}` : ''}${qs.toString() ? `?${qs}` : ''}`;
83
+ const started = Date.now();
84
+ const r = await http(node, path, { method: methodOf({ action }), timeout: 15000 });
85
+ const shape = envelopeShape(r.text);
86
+ let count = '';
87
+ try {
88
+ const env = JSON.parse(r.text);
89
+ const st = JSON.parse(env._status?.[0] || '[]');
90
+ count = st.find((x) => x.name === 'count')?.value || '';
91
+ } catch { /* 非报文回执 */ }
92
+ const result = shape.env?.result?.[0] ?? shape.env?.result ?? '';
93
+ let columns = shape.columns || [];
94
+ let rows = shape.rows || [];
95
+ if (shape.kind === 'text') {
96
+ // 有些命令直接回文本表格(终端视图),解析成列/行
97
+ try { const t = parseFixedTable(r.text); columns = t.columns; rows = t.rows; } catch { /* 保留空 */ }
98
+ }
99
+ return {
100
+ node, space, command, action, status: r.status, ms: Date.now() - started,
101
+ shape: shape.kind === 'text' && rows.length ? 'list' : shape.kind,
102
+ columns, rows: rows.slice(0, 50),
103
+ fields: shape.fields || null, result: typeof result === 'string' ? result : '', count,
104
+ };
105
+ }