dsh-contextos-devops 0.0.0-stage → 1.48.32
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 +92 -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,406 @@
|
|
|
1
|
+
// 核心业务:项目空间生命周期(列表/启停/编译/路径)。
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fsp from 'node:fs';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { http, encode, baseUrl, parseRuntime } from '../base/http.js';
|
|
7
|
+
import { listNodes } from '../base/registry.js';
|
|
8
|
+
import { parseFixedTable } from '../base/table.js';
|
|
9
|
+
import { waitFor } from '../misc/async.js';
|
|
10
|
+
import { goTidyBuild } from './gobuild.js';
|
|
11
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
12
|
+
import { promisify } from 'node:util';
|
|
13
|
+
const execFileAsync = promisify(execFileCb);
|
|
14
|
+
|
|
15
|
+
/** 空间绝对路径:有 space 时落到 usr/local/work/<space>,否则节点根。 */
|
|
16
|
+
export function baseDirOf(n, space) {
|
|
17
|
+
return space ? path.join(n.dir, 'usr', 'local', 'work', space) : n.dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function hashFile(file) {
|
|
21
|
+
try { return crypto.createHash('sha256').update(fsp.readFileSync(file)).digest('hex'); } catch { return ''; }
|
|
22
|
+
}
|
|
23
|
+
async function ensureSpaceBinary(n, space, { force = false, allowNode = true } = {}) {
|
|
24
|
+
const dir = path.join(n.dir, 'usr', 'local', 'work', space);
|
|
25
|
+
const nodeBin = path.join(n.dir, 'bin', 'ice.bin');
|
|
26
|
+
const spaceBin = path.join(dir, 'bin', 'ice.bin');
|
|
27
|
+
if (!fsp.existsSync(nodeBin)) throw new Error(`node :${n.port} has no bin/ice.bin`);
|
|
28
|
+
await fsp.promises.mkdir(path.join(dir, 'bin'), { recursive: true });
|
|
29
|
+
const nodeHash = hashFile(nodeBin);
|
|
30
|
+
const current = fsp.existsSync(spaceBin) ? hashFile(spaceBin) : '';
|
|
31
|
+
if (!force && current && current !== nodeHash) return { path: spaceBin, from: 'existing' };
|
|
32
|
+
const peers = await listNodes();
|
|
33
|
+
const other = peers.filter((x) => x.port !== n.port && x.dir)
|
|
34
|
+
.map((x) => ({ n: x, bin: path.join(x.dir, 'usr', 'local', 'work', space, 'bin', 'ice.bin') }))
|
|
35
|
+
.find((x) => fsp.existsSync(x.bin) && hashFile(x.bin) !== hashFile(path.join(x.n.dir, 'bin', 'ice.bin')));
|
|
36
|
+
if (other?.bin) {
|
|
37
|
+
await fsp.promises.copyFile(other.bin, spaceBin);
|
|
38
|
+
await fsp.promises.chmod(spaceBin, 0o755);
|
|
39
|
+
return { path: spaceBin, from: `space:${other.n.dir}` };
|
|
40
|
+
}
|
|
41
|
+
if (!allowNode) throw new Error(`space ${space} has no compiled binary and no peer space binary to copy`);
|
|
42
|
+
await fsp.promises.copyFile(nodeBin, spaceBin);
|
|
43
|
+
await fsp.promises.chmod(spaceBin, 0o755);
|
|
44
|
+
return { path: spaceBin, from: 'node-bootstrap' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function spaceBase({ node, space }) {
|
|
48
|
+
const n = (await listNodes()).find((x) => x.port === Number(node));
|
|
49
|
+
if (!n) throw new Error(`node ${node} not found`);
|
|
50
|
+
return baseDirOf(n, space);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 项目空间的绝对路径(终端 cwd 用)。 */
|
|
54
|
+
export async function spaceDir({ node, space } = {}) {
|
|
55
|
+
const n = (await listNodes()).find((x) => x.port === Number(node));
|
|
56
|
+
if (!n) throw new Error(`node ${node} not found`);
|
|
57
|
+
return { dir: baseDirOf(n, space) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function startSpace({ node, space } = {}) {
|
|
61
|
+
await http(Number(node), `/c/dream/action/start?name=${encode(space)}&space.noecho=true`, { method: 'POST', timeout: 60000 });
|
|
62
|
+
return { node: Number(node), space, state: 'start' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function stopSpace({ node, space } = {}) {
|
|
66
|
+
await http(Number(node), `/c/dream/action/stop?name=${encode(space)}&space.noecho=true`, { method: 'POST', timeout: 60000 });
|
|
67
|
+
return { node: Number(node), space, state: 'stop' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function compileSpace({ node, space } = {}) {
|
|
71
|
+
const r = await http(node, `/s/${encode(space)}/c/vimer/action/compile`, { method: 'POST', timeout: 120000 });
|
|
72
|
+
return { status: r.status, out: String(r.text || '').slice(0, 4000) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function listSpaces({ node } = {}) {
|
|
76
|
+
const r = await http(node, '/c/dream', { timeout: 10000 });
|
|
77
|
+
const table = parseFixedTable(r.text);
|
|
78
|
+
return table.rows
|
|
79
|
+
.filter((row) => /^\d{8}-[A-Za-z0-9_-]+$/.test(row.name || ''))
|
|
80
|
+
.map((row) => ({
|
|
81
|
+
name: row.name,
|
|
82
|
+
help: (() => { try { return decodeURIComponent(row.help || ''); } catch { return row.help || ''; } })(),
|
|
83
|
+
type: row.type || '',
|
|
84
|
+
status: row.status === 'start' ? 'start' : row.status === 'stop' ? 'stop' : row.status || '',
|
|
85
|
+
running: row.status === 'start',
|
|
86
|
+
version: row.version || '',
|
|
87
|
+
builtAt: (() => {
|
|
88
|
+
const raw = String(row.time || '');
|
|
89
|
+
try { return decodeURIComponent(raw).replace(/\+/g, ' ').slice(0, 19); } catch { return raw.slice(0, 19); }
|
|
90
|
+
})(),
|
|
91
|
+
size: row.size || '',
|
|
92
|
+
dreamStatus: row.status || '',
|
|
93
|
+
icons: (() => { try { return decodeURIComponent(row.icons || ''); } catch { return row.icons || ''; } })(),
|
|
94
|
+
}))
|
|
95
|
+
// 排序:运行中优先,其次名称倒序(新日期在前)
|
|
96
|
+
.sort((a, b) => {
|
|
97
|
+
const ar = a.running ? 1 : 0, br = b.running ? 1 : 0;
|
|
98
|
+
if (ar !== br) return br - ar;
|
|
99
|
+
return String(b.name || '').localeCompare(String(a.name || ''), 'zh-CN', { numeric: true });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function initSpace({ node, space, repos = '' } = {}) {
|
|
104
|
+
const n = (await listNodes()).find((x) => x.port === Number(node));
|
|
105
|
+
if (!n) throw new Error(`node ${node} not found`);
|
|
106
|
+
if (!/^[A-Za-z0-9_-]+$/.test(space || '')) throw new Error('bad space name');
|
|
107
|
+
const short = space.replace(/^\d{8}-/, '');
|
|
108
|
+
// 必须用真实代码库地址:节点按 remote 推导 go.mod 的 module 路径,
|
|
109
|
+
// 传 http://127.0.0.1:<port>/x/<name> 会得到 127.0.0.1 开头的错误模块名。
|
|
110
|
+
const remote = repoUrl(repos) || `https://shylinux.com/x/${short}`;
|
|
111
|
+
const r = await http(node, `/s/${encode(space)}/c/web.code.git.status/action/init?remote=${encode(remote)}&space.noecho=true`, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
timeout: 60000,
|
|
114
|
+
});
|
|
115
|
+
if (r.status !== 200) throw new Error(`init failed: HTTP ${r.status}`);
|
|
116
|
+
const dir = path.join(n.dir, 'usr', 'local', 'work', space);
|
|
117
|
+
await waitFor(async () => fsp.existsSync(path.join(dir, '.git')) && fsp.existsSync(path.join(dir, 'src', 'main.go')), {
|
|
118
|
+
timeout: 20000,
|
|
119
|
+
interval: 600,
|
|
120
|
+
label: `skeleton in ${space}`,
|
|
121
|
+
});
|
|
122
|
+
await ensureSpaceBinary(n, space);
|
|
123
|
+
return { node: Number(node), space, dir, remote, initialized: true };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 在空间目录打开一个系统终端(macOS Terminal)
|
|
127
|
+
export async function openTerminal({ node, space = '' } = {}) {
|
|
128
|
+
const n = (await listNodes()).find((x) => x.port === Number(node));
|
|
129
|
+
if (!n) throw new Error(`node ${node} not found`);
|
|
130
|
+
const dir = baseDirOf(n, space || '');
|
|
131
|
+
const real = await fsp.promises.stat(dir).catch(() => null) ? dir : n.dir; // 目录没生成时退到节点根
|
|
132
|
+
const target = real;
|
|
133
|
+
const { spawn } = await import('node:child_process');
|
|
134
|
+
const child = spawn('open', ['-a', 'Terminal', target], { detached: true, stdio: 'ignore' });
|
|
135
|
+
child.unref();
|
|
136
|
+
return { dir: target, opened: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// go.mod 兜底:框架偶尔会生成不完整的 go.mod(缺 module/go/require),
|
|
140
|
+
// 缺哪个补哪个;两个核心依赖固定为 ice v1.6.6 / icebergs v1.10.7。
|
|
141
|
+
const GO_MOD_DEPS = [
|
|
142
|
+
['shylinux.com/x/ice', 'v1.6.6'],
|
|
143
|
+
['shylinux.com/x/icebergs', 'v1.10.7'],
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
export function fillGoMod(text, module = 'shylinux.com/x/ContextOS') {
|
|
147
|
+
const orig = String(text || '');
|
|
148
|
+
let out = orig;
|
|
149
|
+
const added = [];
|
|
150
|
+
if (!/^\s*module\s+\S+/m.test(out)) {
|
|
151
|
+
out = `module ${module}\n\n${out.trim()}\n`;
|
|
152
|
+
added.push(`module ${module}`);
|
|
153
|
+
}
|
|
154
|
+
if (!/^\s*go\s+\d+(\.\d+)*/m.test(out)) {
|
|
155
|
+
out = out.trimEnd() + '\n\ngo 1.25.0\n';
|
|
156
|
+
added.push('go 1.25.0');
|
|
157
|
+
}
|
|
158
|
+
const missing = GO_MOD_DEPS.filter(([m]) => !new RegExp(`(^|\\s)${m.replace(/\./g, '\\.')}\\s+v`, 'm').test(out));
|
|
159
|
+
if (missing.length) {
|
|
160
|
+
const lines = missing.map(([m, v]) => `\t${m} ${v}`);
|
|
161
|
+
if (/require\s*\(/.test(out)) {
|
|
162
|
+
out = out.replace(/require\s*\(([\s\S]*?)\)/, (all, inner) => `require (${inner.replace(/\s*$/, '\n')}${lines.join('\n')}\n)`);
|
|
163
|
+
} else if (/^\s*require\s+\S+\s+v\S+/m.test(out)) {
|
|
164
|
+
const single = /^\s*require\s+(\S+)\s+(v\S+)\s*$/m.exec(out);
|
|
165
|
+
out = out.replace(single[0], `require (\n\t${single[1]} ${single[2]}\n${lines.join('\n')}\n)`);
|
|
166
|
+
} else {
|
|
167
|
+
out = `${out.trimEnd()}\n\nrequire (\n${lines.join('\n')}\n)\n`;
|
|
168
|
+
}
|
|
169
|
+
added.push(...missing.map(([m, v]) => `${m} ${v}`));
|
|
170
|
+
}
|
|
171
|
+
return { text: out, changed: out !== orig, added };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function ensureGoMod({ node, space }) {
|
|
175
|
+
const n = (await listNodes()).find((x) => x.port === Number(node));
|
|
176
|
+
if (!n) throw new Error(`node ${node} not found`);
|
|
177
|
+
const dir = baseDirOf(n, space);
|
|
178
|
+
const file = path.join(dir, 'go.mod');
|
|
179
|
+
let text = '';
|
|
180
|
+
try { text = await fsp.promises.readFile(file, 'utf8'); } catch { throw new Error(`space is not initialised (no go.mod in ${dir})`); }
|
|
181
|
+
const module = (/^\s*module\s+(\S+)/m.exec(text) || [])[1] || `shylinux.com/x/${space}`;
|
|
182
|
+
const { text: fixed, changed, added } = fillGoMod(text, module);
|
|
183
|
+
if (changed) await fsp.promises.writeFile(file, fixed);
|
|
184
|
+
// 有源码时先 tidy 再补一次:tidy 会把「暂未用到」的依赖删掉,兜底必须以最终文件为准
|
|
185
|
+
const hasSrc = await fsp.promises.stat(path.join(dir, 'src', 'main.go')).catch(() => null);
|
|
186
|
+
if (changed && hasSrc) {
|
|
187
|
+
try { await execFileAsync('go', ['mod', 'tidy'], { cwd: dir, timeout: 240000 }); } catch { /* tidy 失败不影响兜底 */ }
|
|
188
|
+
const now = await fsp.promises.readFile(file, 'utf8').catch(() => '');
|
|
189
|
+
const again = fillGoMod(now, module);
|
|
190
|
+
if (again.changed) {
|
|
191
|
+
await fsp.promises.writeFile(file, again.text);
|
|
192
|
+
added.push(...again.added);
|
|
193
|
+
return { file, changed: true, added };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { file, changed, added };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function buildSpace({ node, space }) {
|
|
200
|
+
const n = (await listNodes()).find((x) => x.port === Number(node));
|
|
201
|
+
if (!n) throw new Error(`node ${node} not found`);
|
|
202
|
+
const dir = path.join(n.dir, 'usr', 'local', 'work', space);
|
|
203
|
+
await ensureGoMod({ node, space }).catch(() => null); // go.mod 不完整时先补齐(框架已知 bug)
|
|
204
|
+
// Canonical compile path: the node pulls the space repos and rebuilds its
|
|
205
|
+
// own bin/ice.bin, then restarts the space. Building locally skips the repo
|
|
206
|
+
// step and leaves the space on the node binary, which is what produced
|
|
207
|
+
// "not found index" for commands the space never had.
|
|
208
|
+
let apiError = '';
|
|
209
|
+
try {
|
|
210
|
+
const r = await http(node, `/c/dream/action/build?name=${encode(space)}`, { method: 'POST', timeout: 900000 });
|
|
211
|
+
if (r.status !== 200) throw new Error(`HTTP ${r.status} ${String(r.text || '').slice(0, 160)}`);
|
|
212
|
+
const body = String(r.text || '');
|
|
213
|
+
if (/"warn"|warn:|"error"/i.test(body) && !/warn":\s*\[\]/.test(body)) {
|
|
214
|
+
// A warn in the build reply means the compile did not fully succeed.
|
|
215
|
+
if (!/build success|_status":\["\[\{"name":"time"/.test(body)) throw new Error(body.slice(0, 200));
|
|
216
|
+
}
|
|
217
|
+
} catch (e) {
|
|
218
|
+
apiError = String(e?.message || e);
|
|
219
|
+
}
|
|
220
|
+
const bin = path.join(dir, 'bin', 'ice.bin');
|
|
221
|
+
const nodeBin = path.join(n.dir, 'bin', 'ice.bin');
|
|
222
|
+
const bad = !fsp.existsSync(bin) || (fsp.existsSync(nodeBin) && hashFile(bin) === hashFile(nodeBin));
|
|
223
|
+
if (apiError || bad) {
|
|
224
|
+
// Fallback: compile in place, then hard-restart the space so it runs the
|
|
225
|
+
// fresh binary instead of whatever it booted with.
|
|
226
|
+
try {
|
|
227
|
+
await goTidyBuild(dir);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
throw new Error(apiError || String(e?.message || e));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (!fsp.existsSync(bin) || (fsp.existsSync(nodeBin) && hashFile(bin) === hashFile(nodeBin))) {
|
|
233
|
+
throw new Error(`space ${space} has no compiled bin/ice.bin${apiError ? ` (build api: ${apiError})` : ''}`);
|
|
234
|
+
}
|
|
235
|
+
await restartSpace({ node, space, dir: n.dir });
|
|
236
|
+
return { space, rebuilt: true, via: apiError ? 'local' : 'dream' };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function restartSpace({ node, space, dir }) {
|
|
240
|
+
await stopSpace({ node, space });
|
|
241
|
+
const pidFile = path.join(dir, 'usr', 'local', 'work', space, 'var', 'log', 'ice.pid');
|
|
242
|
+
const pidAlive = () => {
|
|
243
|
+
if (!fsp.existsSync(pidFile)) return false;
|
|
244
|
+
try { process.kill(Number(fsp.readFileSync(pidFile, 'utf8').trim()), 0); return true; } catch { return false; }
|
|
245
|
+
};
|
|
246
|
+
await waitFor(async () => !pidAlive(), { timeout: 15000, interval: 500, label: `space ${space} stopped` }).catch(() => {});
|
|
247
|
+
// A stale space pid must not survive: it keeps the previous binary running,
|
|
248
|
+
// so commands added by this build stay "not found" until it is replaced.
|
|
249
|
+
if (pidAlive()) {
|
|
250
|
+
try { process.kill(Number(fsp.readFileSync(pidFile, 'utf8').trim()), 'SIGTERM'); } catch {}
|
|
251
|
+
await waitFor(async () => !pidAlive(), { timeout: 8000, interval: 300, label: `space ${space} terminated` }).catch(() => {});
|
|
252
|
+
}
|
|
253
|
+
if (pidAlive()) {
|
|
254
|
+
try { process.kill(Number(fsp.readFileSync(pidFile, 'utf8').trim()), 'SIGKILL'); } catch {}
|
|
255
|
+
await waitFor(async () => !pidAlive(), { timeout: 5000, interval: 200, label: `space ${space} killed` }).catch(() => {});
|
|
256
|
+
}
|
|
257
|
+
await startSpace({ node, space });
|
|
258
|
+
await ensureSpaceProcess({ node, space, dir });
|
|
259
|
+
// /c/runtime answers 200 from the node even when the space has no such
|
|
260
|
+
// command, so it cannot prove the space is serving its own binary. Wait for
|
|
261
|
+
// the space's own pid to be alive AND its homepage to answer instead.
|
|
262
|
+
await waitFor(async () => {
|
|
263
|
+
try {
|
|
264
|
+
const pid = Number((await fsp.promises.readFile(pidFile, 'utf8')).trim());
|
|
265
|
+
if (!(pid > 1)) return false;
|
|
266
|
+
process.kill(pid, 0);
|
|
267
|
+
} catch { return false; }
|
|
268
|
+
const r = await http(node, `/s/${encode(space)}/`, { timeout: 5000, headers: { accept: 'text/html', 'user-agent': 'Mozilla/5.0' } });
|
|
269
|
+
return r.status === 200;
|
|
270
|
+
}, { timeout: 45000, interval: 800, label: `space ${space} serving` });
|
|
271
|
+
}
|
|
272
|
+
export async function ensureSpaceProcess({ node, space, dir }) {
|
|
273
|
+
const pidFile = path.join(dir, 'usr', 'local', 'work', space, 'var', 'log', 'ice.pid');
|
|
274
|
+
const alive = async () => {
|
|
275
|
+
try {
|
|
276
|
+
const pid = Number((await fsp.promises.readFile(pidFile, 'utf8')).trim());
|
|
277
|
+
if (!(pid > 1)) return false;
|
|
278
|
+
process.kill(pid, 0);
|
|
279
|
+
return true;
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
if (await alive()) return;
|
|
285
|
+
await http(node, `/c/dream/action/start?name=${encode(space)}&space.noecho=true`, { method: 'POST', timeout: 60000 });
|
|
286
|
+
await waitFor(alive, { timeout: 20000, interval: 700, label: `space ${space} process` });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function warmSpace({ node, space } = {}) {
|
|
290
|
+
// Opening the space homepage runs its init/DB bootstrap; without this the
|
|
291
|
+
// service/store tables can still be empty right after create/build.
|
|
292
|
+
try {
|
|
293
|
+
const r = await http(node, `/s/${encode(space)}/`, { timeout: 15000, headers: { accept: 'text/html', 'user-agent': 'Mozilla/5.0' } });
|
|
294
|
+
return { status: r.status, bytes: r.size };
|
|
295
|
+
} catch (e) {
|
|
296
|
+
return { error: String(e?.message || e) };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 代码库地址:允许直接给完整 URL(含 git@),否则按仓库名拼到默认代码服务器上。
|
|
301
|
+
// 默认服务器以后可以换成用户自己的:CX_GIT_BASE=https://git.example.com/x/
|
|
302
|
+
export function repoUrl(repos = '') {
|
|
303
|
+
const v = String(repos || '').trim();
|
|
304
|
+
if (!v) return '';
|
|
305
|
+
if (/^(?:https?:\/\/|git@|ssh:\/\/|git:\/\/)/i.test(v)) return v;
|
|
306
|
+
const base = (process.env.CX_GIT_BASE || 'https://shylinux.com/x/').replace(/\/?$/, '/');
|
|
307
|
+
return base + v.replace(/^\/+/, '');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** 节点用 go.work 管理时,把新空间加入 workspace(go work use,失败则文本插入)。 */
|
|
311
|
+
export async function joinNodeWork({ node, space }) {
|
|
312
|
+
const n = (await listNodes()).find((x) => Number(x.port) === Number(node));
|
|
313
|
+
if (!n?.dir) return { used: false, reason: 'node not found' };
|
|
314
|
+
const root = n.dir;
|
|
315
|
+
const work = path.join(root, 'go.work');
|
|
316
|
+
if (!fsp.existsSync(work)) return { used: false, reason: 'no go.work' };
|
|
317
|
+
const rel = `./usr/local/work/${space}`;
|
|
318
|
+
let text = '';
|
|
319
|
+
try { text = fsp.readFileSync(work, 'utf8'); } catch { return { used: false, reason: 'read failed' }; }
|
|
320
|
+
if (text.includes(`usr/local/work/${space}`)) return { used: true, added: false, path: work };
|
|
321
|
+
const run = await new Promise((resolve) => {
|
|
322
|
+
const child = spawn('go', ['work', 'use', rel], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
323
|
+
let err = '';
|
|
324
|
+
child.stderr.on('data', (d) => { err += String(d); });
|
|
325
|
+
child.on('close', (code) => resolve({ code, err }));
|
|
326
|
+
child.on('error', (e) => resolve({ code: -1, err: String(e) }));
|
|
327
|
+
});
|
|
328
|
+
if (run.code === 0) {
|
|
329
|
+
// go work use 可能因为环境(GOFLAGS/GOWORK/HOME)静默不写,必须回读确认
|
|
330
|
+
try {
|
|
331
|
+
const now = fsp.readFileSync(work, 'utf8');
|
|
332
|
+
if (now.includes(`usr/local/work/${space}`)) return { used: true, added: true, via: 'go', path: work };
|
|
333
|
+
} catch { /* fall through to text insert */ }
|
|
334
|
+
}
|
|
335
|
+
const line = `\t${rel}`;
|
|
336
|
+
const next = /use\s*\(([\s\S]*?)\)/.test(text)
|
|
337
|
+
? text.replace(/use\s*\(([\s\S]*?)\)/, (m, body) => `use (${body.replace(/\s*$/, '')}\n${line}\n)`)
|
|
338
|
+
: `${text.replace(/\s*$/, '')}\n\nuse ${rel}\n`;
|
|
339
|
+
try { fsp.writeFileSync(work, next); } catch (e) { return { used: false, reason: String(e?.message || e) }; }
|
|
340
|
+
return { used: true, added: true, via: 'text', path: work };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function createSpace({ node, name, repos = '' }) {
|
|
344
|
+
if (!/^\d{8}-[A-Za-z0-9_]+$/.test(name || '') && !/^[a-zA-Z0-9_]+$/.test(name || '')) {
|
|
345
|
+
throw new Error('space name must be short (letters/digits/_) or date-prefixed (YYYYMMDD-name)');
|
|
346
|
+
}
|
|
347
|
+
const fullName = /^\d{8}-[A-Za-z0-9_]+$/.test(name) ? name : `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${name}`;
|
|
348
|
+
// A standard space is only a skeleton until it knows where its source lives:
|
|
349
|
+
// dream/create clones `repos` into the space before the first build.
|
|
350
|
+
const query = new URLSearchParams({ name: fullName });
|
|
351
|
+
const repo = repoUrl(repos);
|
|
352
|
+
if (repo) query.set('repos', repo);
|
|
353
|
+
const full = fullName;
|
|
354
|
+
let status = '';
|
|
355
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
356
|
+
if (attempt) await new Promise((r) => setTimeout(r, 3000));
|
|
357
|
+
await http(node, `/c/dream/action/create?${query}`, { method: 'PUT', timeout: 30000, auth: false });
|
|
358
|
+
try {
|
|
359
|
+
// The space registers its HTTP surface before the dream row flips from
|
|
360
|
+
// `begin` to `start`; waiting for the pod avoids a 60s stall.
|
|
361
|
+
await waitFor(async () => {
|
|
362
|
+
const r = await http(node, `/s/${encode(full)}/c/runtime`, { timeout: 4000 });
|
|
363
|
+
return r.status === 200 && parseRuntime(r.text).ok; // echo 回执里没有 boot/make,不能算就绪
|
|
364
|
+
}, {
|
|
365
|
+
timeout: 12000,
|
|
366
|
+
interval: 800,
|
|
367
|
+
label: `space ${full} serving`,
|
|
368
|
+
});
|
|
369
|
+
const row = (await listSpaces({ node })).find((s) => s.name === full);
|
|
370
|
+
status = row?.status || '';
|
|
371
|
+
break;
|
|
372
|
+
} catch {
|
|
373
|
+
/* retry create once more */
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (!status) throw new Error(`space ${name} did not answer /c/runtime`);
|
|
377
|
+
const n = (await listNodes()).find((x) => x.port === Number(node));
|
|
378
|
+
if (n) await ensureSpaceBinary(n, full);
|
|
379
|
+
// 仓库不存在/克隆失败时,dream 仍会登记一条记录,但目录不会生成——这里必须报错,不能假装成功
|
|
380
|
+
const dir = baseDirOf(n, full);
|
|
381
|
+
const hasDir = await fsp.promises.stat(dir).catch(() => null);
|
|
382
|
+
if (!hasDir) {
|
|
383
|
+
throw new Error(`空间目录未生成:${dir}(仓库 ${repo || '空空间'} 可能不存在或克隆失败)`);
|
|
384
|
+
}
|
|
385
|
+
const hasMod = await fsp.promises.stat(path.join(dir, 'go.mod')).catch(() => null);
|
|
386
|
+
if (repo && !hasMod) {
|
|
387
|
+
throw new Error(`空间目录不完整:${dir} 里没有 go.mod(仓库 ${repo} 可能不是 ContextOS 源码)`);
|
|
388
|
+
}
|
|
389
|
+
// 节点若用 go.work 管理多个空间,新空间必须加进去,否则它不在 workspace 里
|
|
390
|
+
let work = null;
|
|
391
|
+
try { work = await joinNodeWork({ node, space: full }); } catch (e) { work = { used: false, error: String(e?.message || e) }; }
|
|
392
|
+
return { node, name: full, short: name, status, work };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Create a real ContextOS app: git init + vimer module + code save + compile. */
|
|
396
|
+
/** End-to-end pipeline: node -> space -> app -> record -> read-back. */
|
|
397
|
+
/** 容器部署:docker run 一个 ContextOS 节点,端口映射后等待 /c/runtime。 */
|
|
398
|
+
|
|
399
|
+
/** 文件存储依赖云研发空间(20240903-operation):上传的文件由它统一管理。 */
|
|
400
|
+
export const STORAGE_SPACE = '20240903-operation';
|
|
401
|
+
export async function storageReady({ node } = {}) {
|
|
402
|
+
let spaces = [];
|
|
403
|
+
try { spaces = await listSpaces({ node }); } catch { /* node may be offline */ }
|
|
404
|
+
const hit = spaces.find((s) => s.name === STORAGE_SPACE);
|
|
405
|
+
return { node, space: STORAGE_SPACE, installed: !!hit, running: hit ? /^(start|running)$/i.test(String(hit.status || '')) : false, ready: !!(hit && /^(start|running)$/i.test(String(hit.status || ''))) };
|
|
406
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// 核心业务:内嵌终端(WebSocket + Python pty.fork 真 PTY)。
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import fsp from 'node:fs';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { ptySupported } from '../base/platform.js';
|
|
7
|
+
|
|
8
|
+
// ---- embedded terminal (WebSocket + PTY via /usr/bin/script) ----
|
|
9
|
+
function wsAccept(key) {
|
|
10
|
+
return crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
|
|
11
|
+
}
|
|
12
|
+
function wsFrame(data) {
|
|
13
|
+
const payload = Buffer.isBuffer(data) ? data : Buffer.from(String(data), 'utf8');
|
|
14
|
+
const len = payload.length;
|
|
15
|
+
let header;
|
|
16
|
+
if (len < 126) header = Buffer.from([0x81, len]);
|
|
17
|
+
else if (len < 65536) header = Buffer.from([0x81, 126, (len >> 8) & 0xff, len & 0xff]);
|
|
18
|
+
else {
|
|
19
|
+
header = Buffer.alloc(10);
|
|
20
|
+
header[0] = 0x81; header[1] = 127;
|
|
21
|
+
header.writeBigUInt64BE(BigInt(len), 2);
|
|
22
|
+
}
|
|
23
|
+
return Buffer.concat([header, payload]);
|
|
24
|
+
}
|
|
25
|
+
const PTY_HELPER = `
|
|
26
|
+
import os, sys, pty, select, fcntl, termios, struct, signal
|
|
27
|
+
def main():
|
|
28
|
+
cwd = sys.argv[1]
|
|
29
|
+
shell = sys.argv[2] if len(sys.argv) > 2 else (os.environ.get('SHELL') or '/bin/zsh')
|
|
30
|
+
os.chdir(cwd)
|
|
31
|
+
pid, fd = pty.fork()
|
|
32
|
+
if pid == 0:
|
|
33
|
+
os.environ['TERM'] = 'xterm-256color'
|
|
34
|
+
os.execvp(shell, [shell])
|
|
35
|
+
def nonblock(f):
|
|
36
|
+
fcntl.fcntl(f, fcntl.F_SETFL, fcntl.fcntl(f, fcntl.F_GETFL) | os.O_NONBLOCK)
|
|
37
|
+
nonblock(fd); nonblock(0)
|
|
38
|
+
while True:
|
|
39
|
+
r, _, _ = select.select([fd, 0], [], [])
|
|
40
|
+
if fd in r:
|
|
41
|
+
try:
|
|
42
|
+
data = os.read(fd, 65536)
|
|
43
|
+
if not data: break
|
|
44
|
+
os.write(1, data)
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
if 0 in r:
|
|
48
|
+
try:
|
|
49
|
+
data = os.read(0, 65536)
|
|
50
|
+
if not data: break
|
|
51
|
+
if data.startswith(b'\\x00CTX_RESIZE '):
|
|
52
|
+
try:
|
|
53
|
+
cols, rows = data.decode().strip().split(' ')[1:3]
|
|
54
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack('HHHH', int(rows), int(cols), 0, 0))
|
|
55
|
+
os.kill(pid, signal.SIGWINCH)
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
else:
|
|
59
|
+
os.write(fd, data)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
main()
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
export function terminalUpgrade(req, socket, head) {
|
|
66
|
+
const key = req.headers['sec-websocket-key'];
|
|
67
|
+
if (!key) { socket.destroy(); return; }
|
|
68
|
+
if (!ptySupported) {
|
|
69
|
+
// Windows 无 pty.fork:明确拒绝,客户端会回落为「用 DSH 自带终端」。
|
|
70
|
+
socket.write('HTTP/1.1 501 Not Implemented\r\nConnection: close\r\n\r\n');
|
|
71
|
+
socket.end();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
socket.write(
|
|
75
|
+
'HTTP/1.1 101 Switching Protocols\r\n' +
|
|
76
|
+
'Upgrade: websocket\r\n' +
|
|
77
|
+
'Connection: Upgrade\r\n' +
|
|
78
|
+
`Sec-WebSocket-Accept: ${wsAccept(key)}\r\n\r\n`,
|
|
79
|
+
);
|
|
80
|
+
let cwd = os.homedir();
|
|
81
|
+
try {
|
|
82
|
+
const p = new URL(req.url || '/', 'http://x').searchParams.get('cwd');
|
|
83
|
+
if (p) cwd = decodeURIComponent(p);
|
|
84
|
+
} catch { /* keep home */ }
|
|
85
|
+
if (!fsp.existsSync(cwd)) cwd = os.homedir();
|
|
86
|
+
|
|
87
|
+
const shell = process.env.SHELL || '/bin/zsh';
|
|
88
|
+
const python = process.env.PYTHON || 'python3';
|
|
89
|
+
const child = spawn(python, ['-c', PTY_HELPER, cwd, shell], {
|
|
90
|
+
env: { ...process.env }, stdio: ['pipe', 'pipe', 'pipe'],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
let buf = Buffer.alloc(0);
|
|
94
|
+
const feed = (chunk) => {
|
|
95
|
+
buf = Buffer.concat([buf, chunk]);
|
|
96
|
+
while (buf.length >= 2) {
|
|
97
|
+
const b0 = buf[0], b1 = buf[1];
|
|
98
|
+
const opcode = b0 & 0x0f;
|
|
99
|
+
const masked = (b1 & 0x80) !== 0;
|
|
100
|
+
let len = b1 & 0x7f, off = 2;
|
|
101
|
+
if (len === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); off = 4; }
|
|
102
|
+
else if (len === 127) { if (buf.length < 10) return; len = Number(buf.readBigUInt64BE(2)); off = 10; }
|
|
103
|
+
let mask;
|
|
104
|
+
if (masked) { if (buf.length < off + 4) return; mask = buf.subarray(off, off + 4); off += 4; }
|
|
105
|
+
if (buf.length < off + len) return;
|
|
106
|
+
const payload = Buffer.from(buf.subarray(off, off + len));
|
|
107
|
+
buf = buf.subarray(off + len);
|
|
108
|
+
if (masked) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
|
|
109
|
+
if (opcode === 0x8) { try { child.kill(); } catch {} return; }
|
|
110
|
+
if (opcode === 0x9) { socket.write(wsFrame(Buffer.concat([Buffer.from([0x8a, 0]), payload]))); continue; }
|
|
111
|
+
if (opcode === 0x2 || opcode === 0x1) { try { child.stdin.write(payload); } catch {} }
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
let rxN = 0, ptyAcc = Buffer.alloc(0), ptyTimer = 0;
|
|
115
|
+
const flushPty = () => {
|
|
116
|
+
ptyTimer = 0;
|
|
117
|
+
const raw = ptyAcc.toString('utf8');
|
|
118
|
+
ptyAcc = Buffer.alloc(0);
|
|
119
|
+
// zsh PROMPT_SP:清行标记,某些终端渲染会把它显示成乱码,直接丢弃
|
|
120
|
+
const clean = raw.replace(/\x1b\[1m\x1b\[7m%\x1b\[27m\x1b\[1m\x1b\[0m *\r \r/g, '');
|
|
121
|
+
if (!clean) return;
|
|
122
|
+
if (rxN < 3) {
|
|
123
|
+
rxN++;
|
|
124
|
+
try { fsp.appendFileSync('/tmp/cx-term-rx.log', `--- chunk ${rxN} len=${clean.length} text=${JSON.stringify(clean.slice(0, 120))}\n`); } catch {}
|
|
125
|
+
}
|
|
126
|
+
try { socket.write(wsFrame(Buffer.from(clean, 'utf8'))); } catch {}
|
|
127
|
+
};
|
|
128
|
+
child.stdout.on('data', (d) => { ptyAcc = Buffer.concat([ptyAcc, d]); if (!ptyTimer) ptyTimer = setTimeout(flushPty, 30); });
|
|
129
|
+
child.stderr.on('data', (d) => { try { socket.write(wsFrame(d)); } catch {} });
|
|
130
|
+
child.on('exit', () => { try { socket.end(); } catch {} });
|
|
131
|
+
socket.on('data', feed);
|
|
132
|
+
socket.on('error', () => { try { child.kill(); } catch {} });
|
|
133
|
+
socket.on('close', () => { try { child.kill(); } catch {} });
|
|
134
|
+
if (head && head.length) feed(head);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|