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.
- package/LICENSE +21 -0
- package/README.md +207 -2
- package/assets/avatar.jpg +0 -0
- package/assets/client.css +3316 -0
- package/assets/icon-180.png +0 -0
- package/build/client.mjs +64 -0
- package/client.js +7622 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +15 -0
- package/index.js +345 -0
- package/lib/base/env.js +22 -0
- package/lib/base/field.js +25 -0
- package/lib/base/http.js +140 -0
- package/lib/base/platform.js +33 -0
- package/lib/base/registry.js +186 -0
- package/lib/base/state.js +66 -0
- package/lib/base/table.js +47 -0
- package/lib/base/texttable.js +26 -0
- package/lib/base/url.js +178 -0
- package/lib/core/README.md +17 -0
- package/lib/core/apps.js +675 -0
- package/lib/core/auth.js +110 -0
- package/lib/core/avatar.js +289 -0
- package/lib/core/backup.js +105 -0
- package/lib/core/codegen.js +401 -0
- package/lib/core/copilot.js +419 -0
- package/lib/core/data.js +157 -0
- package/lib/core/device.js +148 -0
- package/lib/core/files.js +152 -0
- package/lib/core/gobuild.js +63 -0
- package/lib/core/iam.js +122 -0
- package/lib/core/identity.js +22 -0
- package/lib/core/nodes.js +627 -0
- package/lib/core/ops.js +306 -0
- package/lib/core/project.js +393 -0
- package/lib/core/spaces.js +406 -0
- package/lib/core/terminal.js +137 -0
- package/lib/core/terminal2.js +141 -0
- package/lib/core/users.js +51 -0
- package/lib/core/vocab.js +158 -0
- package/lib/misc/async.js +15 -0
- package/lib/misc/shell.js +27 -0
- package/lib/misc/static.js +33 -0
- package/locale/en.json +10 -0
- package/locale/zh.json +10 -0
- package/package.json +91 -4
- package/src/client/README.md +37 -0
- package/src/client/parts/00-prelude.js +315 -0
- package/src/client/parts/05-bootstrap.js +61 -0
- package/src/client/parts/08-store.js +85 -0
- package/src/client/parts/10-ui.js +188 -0
- package/src/client/parts/12-debug.js +361 -0
- package/src/client/parts/15-layout.js +86 -0
- package/src/client/parts/20-devworkspace.js +789 -0
- package/src/client/parts/25-editor-ui.js +28 -0
- package/src/client/parts/30-modals.js +85 -0
- package/src/client/parts/32-cards.js +176 -0
- package/src/client/parts/34-forms.js +176 -0
- package/src/client/parts/36-data.js +267 -0
- package/src/client/parts/40-project.js +430 -0
- package/src/client/parts/45-map.js +109 -0
- package/src/client/parts/46-pm.js +158 -0
- package/src/client/parts/47-three.js +324 -0
- package/src/client/parts/49-msg.js +167 -0
- package/src/client/parts/50-chat.js +1422 -0
- package/src/client/parts/51-pmselection.js +31 -0
- package/src/client/parts/52-pmdata.js +29 -0
- package/src/client/parts/55-pm.js +146 -0
- package/src/client/parts/58-config.js +381 -0
- package/src/client/parts/59-monitor.js +262 -0
- package/src/client/parts/60-page.js +1518 -0
- package/src/client/styles/kit.css +20 -0
- package/src/client/styles/main.css +3294 -0
- package/src/client/styles/tokens.css +2 -0
- package/vendor/leaflet/leaflet.css +661 -0
- package/vendor/leaflet/leaflet.js +6 -0
- package/vendor/three/three.module.js +53044 -0
- package/vendor/xterm/addon-fit.js +2 -0
- package/vendor/xterm/xterm.css +218 -0
- package/vendor/xterm/xterm.js +2 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// 核心业务:周边设备发现(ContextOS 节点 / WiFi / 蓝牙 / 局域网)。
|
|
2
|
+
import { runShell } from '../misc/shell.js';
|
|
3
|
+
import { listNodes } from '../base/registry.js';
|
|
4
|
+
|
|
5
|
+
const DEVICE_ADAPTERS = [
|
|
6
|
+
{ kind: 'contextos', label: 'ContextOS 节点', supported: true, reason: '' },
|
|
7
|
+
{ kind: 'wifi', label: 'WiFi / 局域网', supported: process.platform === 'darwin' || process.platform === 'linux', reason: '' },
|
|
8
|
+
{ kind: 'bluetooth', label: '蓝牙', supported: process.platform === 'darwin' || process.platform === 'linux', reason: '' },
|
|
9
|
+
{ kind: 'infrared', label: '红外', supported: false, reason: '当前宿主无红外适配器;预留 IR 适配层' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function walkBluetooth(value, out) {
|
|
13
|
+
if (!value || typeof value !== 'object') return;
|
|
14
|
+
if (Array.isArray(value)) { for (const x of value) walkBluetooth(x, out); return; }
|
|
15
|
+
const keys = Object.keys(value);
|
|
16
|
+
const name = value.device_name || value._name || value.name || value.device_title;
|
|
17
|
+
const addr = value.device_address || value.address || value.device_addr;
|
|
18
|
+
if (name || addr) out.push({ name: String(name || addr), address: String(addr || ''), raw: value });
|
|
19
|
+
for (const k of keys) {
|
|
20
|
+
if (typeof value[k] === 'object') walkBluetooth(value[k], out);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function scanWifiNetworks() {
|
|
25
|
+
const candidates = [
|
|
26
|
+
'/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -s 2>/dev/null',
|
|
27
|
+
'airport -s 2>/dev/null',
|
|
28
|
+
];
|
|
29
|
+
for (const cmd of candidates) {
|
|
30
|
+
const r = await runShell(cmd, process.cwd(), { timeoutMs: 8000 }).catch(() => null);
|
|
31
|
+
if (!r || r.code !== 0 || !String(r.out || '').trim()) continue;
|
|
32
|
+
const rows = [];
|
|
33
|
+
for (const line of r.out.split('\n').slice(1)) {
|
|
34
|
+
const t = line.trim();
|
|
35
|
+
if (!t) continue;
|
|
36
|
+
const m = /^(.*?)\s+([0-9a-f:]{17})\s+(-?\d+)\s+(\d+)(?:\s+.*)?$/i.exec(t);
|
|
37
|
+
if (m) rows.push({ ssid: m[1].trim(), bssid: m[2].toLowerCase(), rssi: Number(m[3]), channel: Number(m[4]) });
|
|
38
|
+
}
|
|
39
|
+
if (rows.length) return rows;
|
|
40
|
+
}
|
|
41
|
+
// 新版 macOS 无 airport:从 system_profiler 的 “Other Local Wi-Fi Networks” 解析
|
|
42
|
+
const prof = await runShell('/usr/sbin/system_profiler SPAirPortDataType 2>/dev/null', process.cwd(), { timeoutMs: 10000 }).catch(() => null);
|
|
43
|
+
if (prof && prof.code === 0) {
|
|
44
|
+
const lines = String(prof.out || '').split('\n');
|
|
45
|
+
const start = lines.findIndex((l) => /Other Local Wi-Fi Networks:/i.test(l));
|
|
46
|
+
const rows = [];
|
|
47
|
+
let cur = null;
|
|
48
|
+
for (let i = start + 1; start >= 0 && i < lines.length; i += 1) {
|
|
49
|
+
const raw = lines[i];
|
|
50
|
+
const t = raw.trim();
|
|
51
|
+
if (!t) continue;
|
|
52
|
+
if (/^[a-z]+\d+:$/.test(t)) break;
|
|
53
|
+
if (/^[A-Za-z].*:$/.test(t) && /^(Interfaces|Current Network|Other Local|Software|AirPort|Wake|AirDrop|Auto|Status)/i.test(t)) break;
|
|
54
|
+
if (/^<redacted>:$/.test(t) || /^.+:$/.test(t) && !/^([A-Z][A-Za-z /]+):/.test(t)) {
|
|
55
|
+
if (cur) rows.push(cur);
|
|
56
|
+
cur = { ssid: /<redacted>/.test(t) ? `网络 ${rows.length + 1}` : t.replace(/:$/, ''), bssid: '', rssi: null, channel: null };
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!cur) continue;
|
|
60
|
+
const ch = /Channel:\s*(\d+)/i.exec(t); if (ch) cur.channel = Number(ch[1]);
|
|
61
|
+
const sg = /Signal \/ Noise:\s*(-?\d+)/i.exec(t); if (sg) cur.rssi = Number(sg[1]);
|
|
62
|
+
}
|
|
63
|
+
if (cur) rows.push(cur);
|
|
64
|
+
if (rows.length) return rows;
|
|
65
|
+
}
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function scanBluetoothNearby() {
|
|
70
|
+
const r = await runShell('blueutil --inquiry 5 --format json 2>/dev/null', process.cwd(), { timeoutMs: 12000 }).catch(() => null);
|
|
71
|
+
if (r && r.code === 0 && String(r.out || '').trim()) {
|
|
72
|
+
try {
|
|
73
|
+
const arr = JSON.parse(r.out);
|
|
74
|
+
if (Array.isArray(arr)) return arr.map((d) => ({ name: d.name || d.address || 'BLE', address: d.address || '', rssi: d.rssi ?? null, nearby: true }));
|
|
75
|
+
} catch { /* fall through */ }
|
|
76
|
+
}
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function scanLanHosts() {
|
|
81
|
+
const r = await runShell('/usr/sbin/arp -a 2>/dev/null', process.cwd(), { timeoutMs: 4000 }).catch(() => null);
|
|
82
|
+
const hosts = [];
|
|
83
|
+
if (r && r.code === 0) {
|
|
84
|
+
for (const line of String(r.out || '').split('\n')) {
|
|
85
|
+
const m = /^(\S+)\s+\(([^)]+)\)\s+at\s+([0-9a-f:]+)/i.exec(line.trim());
|
|
86
|
+
if (!m) continue;
|
|
87
|
+
hosts.push({ ip: m[2], mac: m[3], name: m[1] === '?' ? '' : m[1] });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return hosts;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function discoverDevices({ node } = {}) {
|
|
94
|
+
const devices = [];
|
|
95
|
+
const adapters = DEVICE_ADAPTERS.map((x) => ({ ...x }));
|
|
96
|
+
const nodes = await listNodes();
|
|
97
|
+
for (const n of nodes) {
|
|
98
|
+
devices.push({
|
|
99
|
+
id: `contextos:${n.port}`,
|
|
100
|
+
name: n.name || `ContextOS-${n.port}`,
|
|
101
|
+
kind: 'contextos',
|
|
102
|
+
address: `127.0.0.1:${n.port}`,
|
|
103
|
+
status: n.state,
|
|
104
|
+
controllable: true,
|
|
105
|
+
meta: { dir: n.dir, env: n.env || '', version: n.version || '' },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const wifi = await runShell('/usr/sbin/arp -a 2>/dev/null', process.cwd(), { timeoutMs: 4000 }).catch(() => null);
|
|
109
|
+
if (wifi && wifi.code === 0) {
|
|
110
|
+
for (const line of wifi.out.split('\n')) {
|
|
111
|
+
const m = /^(\S+)\s+\(([^)]+)\)\s+at\s+([0-9a-f:]+)/i.exec(line.trim());
|
|
112
|
+
if (!m) continue;
|
|
113
|
+
devices.push({ id: `wifi:${m[2]}:${m[3]}`, name: m[1] === '?' ? `WiFi ${m[2]}` : m[1], kind: 'wifi', address: m[2], status: 'online', controllable: false, meta: { mac: m[3], raw: line.trim() } });
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
const a = adapters.find((x) => x.kind === 'wifi');
|
|
117
|
+
if (a) { a.supported = false; a.reason = 'arp 不可用或超时'; }
|
|
118
|
+
}
|
|
119
|
+
const bt = await runShell(process.platform === 'darwin' ? 'system_profiler SPBluetoothDataType -json 2>/dev/null' : 'bluetoothctl devices 2>/dev/null', process.cwd(), { timeoutMs: 8000 }).catch(() => null);
|
|
120
|
+
if (bt && bt.code === 0 && bt.out.trim()) {
|
|
121
|
+
let found = [];
|
|
122
|
+
try {
|
|
123
|
+
const json = JSON.parse(bt.out);
|
|
124
|
+
walkBluetooth(json, found);
|
|
125
|
+
} catch {
|
|
126
|
+
for (const line of bt.out.split('\n')) {
|
|
127
|
+
const m = /Device\s+([0-9A-F:]{17})\s+(.+)$/i.exec(line.trim());
|
|
128
|
+
if (m) found.push({ name: m[2], address: m[1] });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const seen = new Set();
|
|
132
|
+
for (const d of found) {
|
|
133
|
+
const key = `${d.address}:${d.name}`;
|
|
134
|
+
if (seen.has(key)) continue;
|
|
135
|
+
seen.add(key);
|
|
136
|
+
devices.push({ id: `bluetooth:${d.address || d.name}`, name: d.name, kind: 'bluetooth', address: d.address || '', status: 'paired', controllable: false, meta: {} });
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
const a = adapters.find((x) => x.kind === 'bluetooth');
|
|
140
|
+
if (a) { a.reason = bt ? '蓝牙不可用或超时' : '蓝牙扫描失败'; }
|
|
141
|
+
}
|
|
142
|
+
const [wifiNetworks, btNearby, lanHosts] = await Promise.all([scanWifiNetworks(), scanBluetoothNearby(), scanLanHosts()]);
|
|
143
|
+
return {
|
|
144
|
+
adapters, devices, wifiNetworks, bluetoothNearby: btNearby, lanHosts,
|
|
145
|
+
scannedAt: new Date().toISOString(),
|
|
146
|
+
note: 'macOS 会隐藏未授权的 Wi-Fi 名称(显示为 网络 N);附近蓝牙需要安装 blueutil;局域网来自 arp 邻居表;ContextOS 节点可控制。',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// 核心业务:空间文件操作(列目录/读写/树/新建/改名/删除/git 状态)。
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import fsp from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { listNodes } from '../base/registry.js';
|
|
7
|
+
import { spaceBase, baseDirOf } from './spaces.js';
|
|
8
|
+
|
|
9
|
+
export async function listDir({ node, space, path: sub = '' } = {}) {
|
|
10
|
+
const n = (await listNodes()).find((x) => x.port === Number(node));
|
|
11
|
+
if (!n) throw new Error(`node ${node} not found`);
|
|
12
|
+
const base = path.join(n.dir, 'usr', 'local', 'work', space);
|
|
13
|
+
const dir = sub ? path.join(base, ...sub.split('/').filter(Boolean)) : base;
|
|
14
|
+
if (dir !== base && !dir.startsWith(base + path.sep)) throw new Error('path escapes the space');
|
|
15
|
+
let entries;
|
|
16
|
+
try {
|
|
17
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
18
|
+
} catch (e) {
|
|
19
|
+
throw new Error(`cannot read ${dir}: ${String(e?.message || e)}`);
|
|
20
|
+
}
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const ent of entries) {
|
|
23
|
+
if (ent.name === '.git') continue;
|
|
24
|
+
const p = path.join(dir, ent.name);
|
|
25
|
+
let st = null;
|
|
26
|
+
try { st = await fs.stat(p); } catch {}
|
|
27
|
+
out.push({
|
|
28
|
+
name: ent.name,
|
|
29
|
+
type: ent.isDirectory() ? 'dir' : 'file',
|
|
30
|
+
size: st?.size ?? 0,
|
|
31
|
+
mtime: st ? st.mtime.toISOString() : '',
|
|
32
|
+
path: sub ? `${sub}/${ent.name}` : ent.name,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const ORDER = ['src', 'etc', 'bin', 'var', 'usr'];
|
|
36
|
+
const rank = (e) => { const i = ORDER.indexOf(e.name); return i < 0 ? 99 : i; };
|
|
37
|
+
out.sort((a, b) => {
|
|
38
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
39
|
+
if (a.type === 'dir') {
|
|
40
|
+
const ra = rank(a), rb = rank(b);
|
|
41
|
+
if (ra !== rb) return ra - rb;
|
|
42
|
+
}
|
|
43
|
+
return a.name.localeCompare(b.name);
|
|
44
|
+
});
|
|
45
|
+
return { node: Number(node), space, path: sub || '', entries: out.filter((e) => !e.name.startsWith('.')) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readFile({ node, space, path: p } = {}) {
|
|
49
|
+
const n = (await listNodes()).find((x) => x.port === Number(node));
|
|
50
|
+
if (!n) throw new Error(`node ${node} not found`);
|
|
51
|
+
const base = baseDirOf(n, space);
|
|
52
|
+
const file = path.join(base, ...String(p || '').replace(/^\/+/, '').split('/').filter(Boolean));
|
|
53
|
+
if (file !== base && !file.startsWith(base + path.sep)) throw new Error('path escapes the space');
|
|
54
|
+
let content;
|
|
55
|
+
try {
|
|
56
|
+
content = await fs.readFile(file, 'utf8');
|
|
57
|
+
} catch (e) {
|
|
58
|
+
throw new Error(`cannot read ${file}: ${String(e?.message || e)}`);
|
|
59
|
+
}
|
|
60
|
+
return { node: Number(node), space, path: p || '', content };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function writeFile({ node, space, path: p, content = '' } = {}) {
|
|
64
|
+
const n = (await listNodes()).find((x) => x.port === Number(node));
|
|
65
|
+
if (!n) throw new Error(`node ${node} not found`);
|
|
66
|
+
const base = baseDirOf(n, space);
|
|
67
|
+
const file = path.join(base, ...String(p || '').replace(/^\/+/, '').split('/').filter(Boolean));
|
|
68
|
+
if (file !== base && !file.startsWith(base + path.sep)) throw new Error('path escapes the space');
|
|
69
|
+
await fs.writeFile(file, content, 'utf8');
|
|
70
|
+
return { node: Number(node), space, path: p || '', saved: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 列出项目空间内的目录项(IDE 文件树用)。 */
|
|
74
|
+
export async function listTree({ node, space, path: p = '' } = {}) {
|
|
75
|
+
const n = (await listNodes()).find((x) => x.port === Number(node));
|
|
76
|
+
if (!n) throw new Error(`node ${node} not found`);
|
|
77
|
+
const base = baseDirOf(n, space);
|
|
78
|
+
const dir = path.join(base, ...String(p || '').replace(/^\/+/, '').split('/').filter(Boolean));
|
|
79
|
+
if (dir !== base && !dir.startsWith(base + path.sep)) throw new Error('path escapes the space');
|
|
80
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
81
|
+
const skip = new Set(['.git', 'var', 'bin', 'node_modules']);
|
|
82
|
+
return entries
|
|
83
|
+
.filter((e) => !skip.has(e.name))
|
|
84
|
+
.map((e) => ({ name: e.name, type: e.isDirectory() ? 'dir' : 'file', path: [p, e.name].filter(Boolean).join('/') }))
|
|
85
|
+
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 项目空间内路径解析(防越界)。 */
|
|
89
|
+
function resolveIn(base, p) {
|
|
90
|
+
const full = path.join(base, ...String(p || '').split('/').filter(Boolean));
|
|
91
|
+
if (full !== base && !full.startsWith(base + path.sep)) throw new Error('path escapes the space');
|
|
92
|
+
return full;
|
|
93
|
+
}
|
|
94
|
+
export async function makeDir({ node, space, path: p = '' } = {}) {
|
|
95
|
+
const base = await spaceBase({ node, space });
|
|
96
|
+
await fs.mkdir(resolveIn(base, p), { recursive: true });
|
|
97
|
+
return { path: p, created: true };
|
|
98
|
+
}
|
|
99
|
+
export async function renamePath({ node, space, from = '', to = '' } = {}) {
|
|
100
|
+
const base = await spaceBase({ node, space });
|
|
101
|
+
if (!from || !to) throw new Error('from/to are required');
|
|
102
|
+
await fs.rename(resolveIn(base, from), resolveIn(base, to));
|
|
103
|
+
return { from, to, renamed: true };
|
|
104
|
+
}
|
|
105
|
+
export async function removePath({ node, space, path: p = '' } = {}) {
|
|
106
|
+
const base = await spaceBase({ node, space });
|
|
107
|
+
if (!p) throw new Error('path is required');
|
|
108
|
+
await fs.rm(resolveIn(base, p), { recursive: true, force: true });
|
|
109
|
+
return { path: p, removed: true };
|
|
110
|
+
}
|
|
111
|
+
export async function gitStatus({ node, space } = {}) {
|
|
112
|
+
const base = await spaceBase({ node, space });
|
|
113
|
+
const run = (args) => new Promise((resolve) => {
|
|
114
|
+
const child = spawn('git', ['-C', base, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
115
|
+
let out = '';
|
|
116
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
117
|
+
child.stderr.on('data', (d) => { out += d; });
|
|
118
|
+
child.on('close', (code) => resolve({ code, out }));
|
|
119
|
+
child.on('error', () => resolve({ code: -1, out: '' }));
|
|
120
|
+
});
|
|
121
|
+
const st = await run(['status', '--porcelain=v1', '-b']);
|
|
122
|
+
if (st.code !== 0) return { branch: '', ahead: 0, behind: 0, files: {}, changed: 0, commits: 0, error: st.out.slice(0, 200) };
|
|
123
|
+
const files = {};
|
|
124
|
+
let branch = '', ahead = 0, behind = 0;
|
|
125
|
+
st.out.split('\n').forEach((line) => {
|
|
126
|
+
if (!line) return;
|
|
127
|
+
if (line.startsWith('## ')) {
|
|
128
|
+
const head = line.slice(3);
|
|
129
|
+
const m = head.match(/^[^.\s]+/);
|
|
130
|
+
branch = m ? m[0] : '';
|
|
131
|
+
const a = head.match(/ahead (\d+)/); if (a) ahead = Number(a[1]);
|
|
132
|
+
const b = head.match(/behind (\d+)/); if (b) behind = Number(b[1]);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const xy = line.slice(0, 2).trim();
|
|
136
|
+
let file = line.slice(3);
|
|
137
|
+
if (file.includes(' -> ')) file = file.split(' -> ').pop();
|
|
138
|
+
if (xy) files[file] = xy;
|
|
139
|
+
});
|
|
140
|
+
const hist = await run(['rev-list', '--count', 'HEAD']);
|
|
141
|
+
const log = await run(['log', '-n', '40', '--pretty=format:%h%x1f%s%x1f%an%x1f%ar']);
|
|
142
|
+
const history = log.code === 0
|
|
143
|
+
? log.out.split('\n').filter(Boolean).map((line) => { const [hash, subject, author, when] = line.split('\x1f'); return { hash, subject, author, when }; })
|
|
144
|
+
: [];
|
|
145
|
+
return { branch, ahead, behind, files, changed: Object.keys(files).length, commits: Number((hist.out || '').trim()) || 0, history };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 项目空间的绝对路径(终端 cwd 用)。 */
|
|
149
|
+
/** Node-level recent apps: gonganxitong.recent for the persistent DSH user. */
|
|
150
|
+
/** Filesystem fallback: every src/<name>/portal.json is one app. */
|
|
151
|
+
/** git init after dream create: required remote, otherwise no skeleton is written. */
|
|
152
|
+
/** Platform-style app: service/autogen, then compile + verify portal. */
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// 核心业务:用本地 Go 工具链构建空间,不依赖宿主进程的 PATH。
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fsp from 'node:fs';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { ctxRoot } from '../base/env.js';
|
|
6
|
+
|
|
7
|
+
export function goBinary() {
|
|
8
|
+
const candidates = [
|
|
9
|
+
process.env.GO,
|
|
10
|
+
path.join(ctxRoot(), 'usr', 'local', 'go', 'bin', 'go'),
|
|
11
|
+
'/usr/local/go/bin/go',
|
|
12
|
+
].filter(Boolean);
|
|
13
|
+
return candidates.find((p) => fsp.existsSync(p)) || '';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function runGo(args, { cwd, timeout = 120000, env = null } = {}) {
|
|
17
|
+
const go = goBinary();
|
|
18
|
+
if (!go) return Promise.reject(new Error('Go toolchain not found; set GO or install usr/local/go'));
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const child = spawn(go, args, {
|
|
21
|
+
cwd,
|
|
22
|
+
env: {
|
|
23
|
+
...process.env,
|
|
24
|
+
...(env || {}),
|
|
25
|
+
PATH: `${path.dirname(go)}:/usr/local/bin:/usr/bin:/bin`,
|
|
26
|
+
CGO_ENABLED: '0',
|
|
27
|
+
GOPROXY: process.env.GOPROXY || 'https://goproxy.cn,direct',
|
|
28
|
+
},
|
|
29
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
30
|
+
});
|
|
31
|
+
let out = '';
|
|
32
|
+
let err = '';
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
child.kill('SIGKILL');
|
|
35
|
+
reject(new Error(`go ${args[0]} timed out`));
|
|
36
|
+
}, timeout);
|
|
37
|
+
child.stdout.on('data', (d) => { out += String(d); });
|
|
38
|
+
child.stderr.on('data', (d) => { err += String(d); });
|
|
39
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
40
|
+
child.on('close', (code) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
if (code === 0) resolve({ out, err });
|
|
43
|
+
else reject(new Error(`go ${args.join(' ')} failed (${code}): ${(err || out).trim().slice(-1200)}`));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build `bin/ice.bin` for one space. */
|
|
49
|
+
export async function goTidyBuild(dir) {
|
|
50
|
+
// 空间是独立模块:必须关掉节点的 go.work,否则 go 会把「主模块」认成
|
|
51
|
+
// shylinux.com/x/ContextOS,报 "main module ... does not contain package".
|
|
52
|
+
const real = await fsp.promises.realpath(dir).catch(() => dir);
|
|
53
|
+
if (!fsp.existsSync(path.join(real, 'go.mod'))) {
|
|
54
|
+
throw new Error(`space is not initialised (no go.mod in ${real})`);
|
|
55
|
+
}
|
|
56
|
+
const env = { ...process.env, GOWORK: 'off', GO111MODULE: 'on', GOFLAGS: '' };
|
|
57
|
+
await runGo(['mod', 'tidy'], { cwd: real, timeout: 180000, env });
|
|
58
|
+
await runGo(['build', '-ldflags', '-w -s', '-o', 'bin/ice.bin', './src'], {
|
|
59
|
+
cwd: real,
|
|
60
|
+
timeout: 300000,
|
|
61
|
+
env,
|
|
62
|
+
});
|
|
63
|
+
}
|
package/lib/core/iam.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// 用户 / 会话 / 权限的统一入口。
|
|
2
|
+
//
|
|
3
|
+
// 系统级(节点自身) 用户 aaa.user 会话 aaa.sess 权限 aaa.role
|
|
4
|
+
// 平台级(云社区) 用户 web.team.gonganxitong.user 会话 web.team.gonganxitong.sess
|
|
5
|
+
// 应用级(每个实例) 成员 web.team.<mod>.member 申请 web.team.<mod>.apply 审批 web.team.<mod>.allow
|
|
6
|
+
import { http, encode, envelopeRows } from '../base/http.js';
|
|
7
|
+
import { parseTextTable } from '../base/texttable.js';
|
|
8
|
+
import { fieldSegments } from '../base/field.js';
|
|
9
|
+
|
|
10
|
+
// 系统级命令在 ice 里注册了短名:user / sess / role(aaa.* 是完整名,两者等价)
|
|
11
|
+
export const IAM = {
|
|
12
|
+
system: { user: 'user', sess: 'sess', role: 'role' },
|
|
13
|
+
platform: { user: 'web.team.gonganxitong.user', sess: 'web.team.gonganxitong.sess' },
|
|
14
|
+
instance: { member: 'web.team.<mod>.member', apply: 'web.team.<mod>.apply', allow: 'web.team.<mod>.allow' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function iamCommand({ level = 'system', kind = 'user', mod = '' }) {
|
|
18
|
+
const table = IAM[level];
|
|
19
|
+
if (!table) throw new Error(`unknown level: ${level}`);
|
|
20
|
+
const tpl = table[kind];
|
|
21
|
+
if (!tpl) throw new Error(`unknown kind: ${level}.${kind}`);
|
|
22
|
+
if (!mod) return tpl;
|
|
23
|
+
return tpl.replace('<mod>', mod);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function iamPath({ level, node, space, command, uid = '' }) {
|
|
27
|
+
if (level === 'system') return `/c/${command}${uid ? `?user.uid=${encode(uid)}` : ''}`;
|
|
28
|
+
if (!space) throw new Error(`${level} commands need a space`);
|
|
29
|
+
return `/s/${encode(space)}/c/${command}${uid ? `?user.uid=${encode(uid)}` : ''}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 列表:系统级是文本表,平台/应用级是 JSON 信封,两者统一成 {columns, rows, url}。 */
|
|
33
|
+
export async function iamList({ node, level = 'system', kind = 'user', space = '', mod = '', uid = '' }) {
|
|
34
|
+
const command = iamCommand({ level, kind, mod });
|
|
35
|
+
const path = iamPath({ level, node, space, command, uid });
|
|
36
|
+
const r = await http(node, path, { timeout: 15000, auth: !uid });
|
|
37
|
+
const url = `http://127.0.0.1:${node}${path}`;
|
|
38
|
+
if (level === 'system') {
|
|
39
|
+
const t = parseTextTable(r.text);
|
|
40
|
+
return { ...t, command, url, status: r.status, empty: t.rows.length === 0 };
|
|
41
|
+
}
|
|
42
|
+
let env = null;
|
|
43
|
+
try { env = JSON.parse(r.text); } catch { env = null; }
|
|
44
|
+
if (env && Array.isArray(env.append) && env.append.length) {
|
|
45
|
+
const { columns, rows } = envelopeRows(r.text);
|
|
46
|
+
return { columns, rows, command, url, status: r.status, empty: rows.length === 0 };
|
|
47
|
+
}
|
|
48
|
+
// 没有行:把回执摘要带回去,界面据此提示「需要实例上下文 / 暂无可管理数据」
|
|
49
|
+
const status = (() => { try { return JSON.parse((env && env._status) ? env._status[0] : '[]'); } catch { return []; } })();
|
|
50
|
+
return { columns: [], rows: [], command, url, status: r.status, empty: true, raw: status };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 通用动作:create / modify / remove / allow / apply …(PUT,字段走路径段)。 */
|
|
54
|
+
export async function iamCall({ node, level = 'system', kind = 'user', space = '', mod = '', action = 'create', fields = {}, args = [], uid = '' }) {
|
|
55
|
+
const command = iamCommand({ level, kind, mod });
|
|
56
|
+
const base = iamPath({ level, node, space, command, uid: '' });
|
|
57
|
+
// 系统级命令多是位置参数(role create <name>),平台/应用级多是 k/v 段
|
|
58
|
+
const seg = args && args.length
|
|
59
|
+
? args.map((v) => encode(v)).join('/')
|
|
60
|
+
: fieldSegments(fields || {});
|
|
61
|
+
const q = new URLSearchParams();
|
|
62
|
+
if (uid) q.set('user.uid', uid);
|
|
63
|
+
if (level === 'system' && !space) { /* 系统级不需要 pod */ }
|
|
64
|
+
const path = `${base}/action/${action}${seg ? '/' + seg : ''}${q.toString() ? '?' + q : ''}`;
|
|
65
|
+
const r = await http(node, path, { method: 'PUT', timeout: 20000, headers: { accept: 'application/json' } });
|
|
66
|
+
let env = null;
|
|
67
|
+
try { env = JSON.parse(r.text); } catch { env = null; }
|
|
68
|
+
const status = (() => { try { return JSON.parse((env && env._status) ? env._status[0] : '[]'); } catch { return []; } })();
|
|
69
|
+
const count = (status.find((x) => x.name === 'count') || {}).value || '';
|
|
70
|
+
const result = (env && env.result) || [];
|
|
71
|
+
const ok = r.status === 200 && !!env && count !== '0x0' ? true : r.status === 200 && !env;
|
|
72
|
+
return { ok, status: r.status, count, result, command, url: `http://127.0.0.1:${node}${path}`, text: env ? '' : String(r.text || '').slice(0, 200) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 平台主体:一个平台里固定五类主体。
|
|
77
|
+
* 平台 / 城市 / 公司(街道、学校、医院等组织) / 服务(应用实例) / 个人。
|
|
78
|
+
*/
|
|
79
|
+
export const SUBJECTS = [
|
|
80
|
+
{ type: 'platform', label: '平台', command: 'web.team.gonganxitong.place' },
|
|
81
|
+
{ type: 'city', label: '城市', command: 'web.team.gonganxitong.city' },
|
|
82
|
+
{ type: 'org', label: '公司/组织', command: 'web.team.gonganxitong.street' },
|
|
83
|
+
{ type: 'service', label: '服务', command: 'web.team.gonganxitong.service' },
|
|
84
|
+
{ type: 'user', label: '个人', command: 'web.team.gonganxitong.user' },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/** 资金与交易:现在存在的是经费 / 广场 / 名片,订单与账单尚未实现。 */
|
|
88
|
+
export const FUND = [
|
|
89
|
+
{ type: 'fund', label: '经费', command: 'web.team.gonganxitong.fund', ready: true },
|
|
90
|
+
{ type: 'market', label: '人民广场', command: 'web.team.gonganxitong.market', ready: true },
|
|
91
|
+
{ type: 'credit', label: '服务名片', command: 'web.team.gonganxitong.credit', ready: true },
|
|
92
|
+
{ type: 'order', label: '订单', command: 'web.team.gonganxitong.order', ready: false },
|
|
93
|
+
{ type: 'bill', label: '账单', command: 'web.team.gonganxitong.bill', ready: false },
|
|
94
|
+
{ type: 'account', label: '资金账户', command: 'web.team.gonganxitong.account', ready: false },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/** 命令是否存在:真命令才有 index / help / list;不存在的命令只是回声信封。 */
|
|
98
|
+
export async function commandExists({ node, space, command }) {
|
|
99
|
+
const r = await http(node, `/s/${encode(space)}/c/command/${command}`, { timeout: 10000 });
|
|
100
|
+
try {
|
|
101
|
+
const env = JSON.parse(r.text);
|
|
102
|
+
return !!(env.index || env.help || env.list || env.meta);
|
|
103
|
+
} catch { return false; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function subjectEndpoints({ node, space = '20240724-community' }) {
|
|
107
|
+
const host = `http://127.0.0.1:${node}/s/${space}/c/`;
|
|
108
|
+
return SUBJECTS.map((x) => ({ ...x, url: host + x.command }));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 管理矩阵:把三层入口列出来,界面可以直接打开对应页面。 */
|
|
112
|
+
export function iamEndpoints({ node, space = '20240724-community', mod = '' }) {
|
|
113
|
+
const host = `http://127.0.0.1:${node}`;
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const [kind, command] of Object.entries(IAM.system)) out.push({ level: 'system', kind, command, url: `${host}/c/${command}` });
|
|
116
|
+
for (const [kind, command] of Object.entries(IAM.platform)) out.push({ level: 'platform', kind, command, url: `${host}/s/${space}/c/${command}` });
|
|
117
|
+
if (mod) for (const [kind, tpl] of Object.entries(IAM.instance)) {
|
|
118
|
+
const command = tpl.replace('<mod>', mod);
|
|
119
|
+
out.push({ level: 'instance', kind, command, url: `${host}/s/${space}/c/${command}` });
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// 唯一身份:创建、访问、最近应用、打开网页都用同一个 user.uid。
|
|
2
|
+
// 解析顺序:显式传入 → 节点上「当前分身」→ 节点自有账号。
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { stateDir } from '../base/env.js';
|
|
6
|
+
import { ensureUser } from './users.js';
|
|
7
|
+
|
|
8
|
+
const FILE = () => path.join(stateDir(), 'avatars.json');
|
|
9
|
+
|
|
10
|
+
export async function activeAvatarUid(node) {
|
|
11
|
+
try {
|
|
12
|
+
const all = JSON.parse(await fs.readFile(FILE(), 'utf8'));
|
|
13
|
+
const bucket = all[String(Number(node))] || {};
|
|
14
|
+
const items = Array.isArray(bucket.items) ? bucket.items : [];
|
|
15
|
+
const hit = items.find((x) => x && x.uid === bucket.active) || items[0];
|
|
16
|
+
return hit && hit.uid ? String(hit.uid) : '';
|
|
17
|
+
} catch { return ''; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function resolveActorUid(node, uid = '') {
|
|
21
|
+
return uid || await activeAvatarUid(node) || await ensureUser(node);
|
|
22
|
+
}
|