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.
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 +92 -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,627 @@
1
+ // 核心业务:服务节点(部署/启停/打开/终端/删除/官方空间/容器与远程部署/流水线)。
2
+ import fs from 'node:fs/promises';
3
+ import fsp from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+ import { runShell } from '../misc/shell.js';
8
+ import { http, encode, probe, portFree } from '../base/http.js';
9
+ import { waitFor } from '../misc/async.js';
10
+ import { HOME, ctxRoot, stateDir } from '../base/env.js';
11
+ import { openBrowser as platformOpenBrowser, openTerminalAt as platformOpenTerminal, platformTriple } from '../base/platform.js';
12
+ import { listNodes, readRegistry, writeRegistry } from '../base/registry.js';
13
+ import { buildSpace, createSpace, listSpaces, startSpace, stopSpace, warmSpace } from './spaces.js';
14
+
15
+ function platformName() {
16
+ return platformTriple();
17
+ }
18
+
19
+ /** 从目录名 / ContextOS-<port> 尾部推端口;读 var/log/ice.port 优先。 */
20
+ /** 磁盘扫描:任何含 bin/ice.bin 的 ~/ContextOS* 目录都是一个节点(对齐 CtxDeployer.scanLocalInstalls)。 */
21
+
22
+
23
+ export async function sshHosts() {
24
+ const home = os.homedir();
25
+ const read = async (p) => { try { return await fs.readFile(p, 'utf8'); } catch { return ''; } };
26
+ const known = await read(path.join(home, '.ssh', 'known_hosts'));
27
+ const hosts = [];
28
+ for (const line of known.split('\n')) {
29
+ const t = line.trim();
30
+ if (!t || t.startsWith('#')) continue;
31
+ const parts = t.split(/\s+/);
32
+ const addr = parts[0];
33
+ if (!addr) continue;
34
+ for (const h of addr.split(',')) {
35
+ const plain = h.replace(/^\[/, '').replace(/\]:\d+$/, '');
36
+ hosts.push({ host: /^\|1\|/.test(h) ? '(hashed)' : plain, address: h, hashed: /^\|1\|/.test(h) });
37
+ }
38
+ }
39
+ const cfg = await read(path.join(home, '.ssh', 'config'));
40
+ const configHosts = [];
41
+ let cur = null;
42
+ for (const line of cfg.split('\n')) {
43
+ const m = /^\s*Host\s+(.+)$/i.exec(line);
44
+ if (m) {
45
+ const name = m[1].trim();
46
+ if (/[*?]/.test(name)) { cur = null; continue; }
47
+ cur = { name, hostname: '', user: '', port: '' };
48
+ configHosts.push(cur);
49
+ continue;
50
+ }
51
+ if (!cur) continue;
52
+ const hn = /^\s*HostName\s+(.+)$/i.exec(line); if (hn) cur.hostname = hn[1].trim();
53
+ const u = /^\s*User\s+(.+)$/i.exec(line); if (u) cur.user = u[1].trim();
54
+ const pt = /^\s*Port\s+(\d+)/i.exec(line); if (pt) cur.port = pt[1];
55
+ }
56
+ const hist = await read(path.join(home, '.zsh_history')) + '\n' + await read(path.join(home, '.bash_history'));
57
+ const cmds = [];
58
+ for (const line of hist.split('\n')) {
59
+ const m = /(?:^|;)\s*(ssh\s+[^\n]+)$/.exec(line);
60
+ if (!m) continue;
61
+ const raw = m[1].trim();
62
+ const tm = /ssh\s+(?:-\S+\s+(?:\S+\s+)?)*([^\s]+)/.exec(raw);
63
+ if (!tm) continue;
64
+ const target = tm[1].replace(/["']/g, '');
65
+ if (!target || /^[#-]/.test(target) || !/^[A-Za-z0-9_.@:-]+$/.test(target)) continue;
66
+ cmds.push({ target, raw: raw.slice(0, 200) });
67
+ }
68
+ const keys = await read(path.join(home, '.ssh', 'authorized_keys'));
69
+ const services = [];
70
+ try {
71
+ const dir = path.join(home, '.ssh', 'host');
72
+ const names = await fs.readdir(dir);
73
+ for (const f of names.sort()) {
74
+ if (!f.endsWith('.json')) continue;
75
+ try {
76
+ const d = JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'));
77
+ services.push({
78
+ name: f.replace(/\.json$/, ''),
79
+ username: d.username || '',
80
+ host: d.host || '',
81
+ port: d.port || '22',
82
+ init: d.init || '',
83
+ });
84
+ } catch { /* skip broken json */ }
85
+ }
86
+ } catch { /* no host dir */ }
87
+ const uniq = (arr, key) => [...new Map(arr.map((x) => [x[key], x])).values()];
88
+ return {
89
+ services,
90
+ knownHosts: uniq(hosts, 'address').slice(0, 100),
91
+ configHosts: uniq(configHosts, 'name').slice(0, 50),
92
+ history: uniq(cmds, 'target').slice(0, 60),
93
+ authorizedKeys: keys.split('\n').filter((l) => l.trim() && !l.startsWith('#')).length,
94
+ };
95
+ }
96
+
97
+ export async function sysControl({ action, value } = {}) {
98
+ const a = String(action || '').toLowerCase();
99
+ const v = value == null ? '' : String(value);
100
+ const run = (cmd, timeoutMs = 8000) => runShell(cmd, process.cwd(), { timeoutMs });
101
+ if (a === 'volume') {
102
+ if (v === '') { const r = await run("osascript -e 'output volume of (get volume settings)'"); return { action: a, result: String(r.out || '').trim() }; }
103
+ const n = Math.max(0, Math.min(100, Number(v) || 0));
104
+ await run(`osascript -e 'set volume output volume ${n}'`);
105
+ return { action: a, result: `volume=${n}` };
106
+ }
107
+ if (a === 'mute') {
108
+ const on = ['1', 'true', 'on', 'yes', '是'].includes(v.toLowerCase());
109
+ await run(`osascript -e 'set volume output muted ${on ? 'true' : 'false'}'`);
110
+ return { action: a, result: on ? 'muted' : 'unmuted' };
111
+ }
112
+ if (a === 'clipboard') {
113
+ if (v === '') { const r = await run('pbpaste'); return { action: a, result: String(r.out || '').slice(0, 20000) }; }
114
+ const file = path.join(stateDir(), 'clipboard.txt');
115
+ await fs.writeFile(file, v);
116
+ await run(`pbcopy < ${JSON.stringify(file)}`);
117
+ return { action: a, result: `copied ${v.length} chars` };
118
+ }
119
+ if (a === 'screenshot') {
120
+ const file = path.join(stateDir(), `screenshot-${Date.now()}.png`);
121
+ await run(`screencapture -x ${JSON.stringify(file)}`, 12000);
122
+ let bytes = 0;
123
+ try { bytes = (await fs.stat(file)).size; } catch {}
124
+ if (!bytes) throw new Error('截屏失败:需要给 DSH 授予「屏幕录制」权限(系统设置 → 隐私与安全性 → 屏幕录制)');
125
+ return { action: a, result: file, path: file, bytes };
126
+ }
127
+ if (a === 'open') {
128
+ if (!/^(https?:\/\/|\/|file:)/i.test(v)) throw new Error('open 只接受 http(s) URL 或绝对路径');
129
+ await run(`open ${JSON.stringify(v)}`);
130
+ return { action: a, result: v };
131
+ }
132
+ if (a === 'wifi') {
133
+ if (v === 'on' || v === 'off') await run(`networksetup -setairportpower en0 ${v}`);
134
+ const r = await run('networksetup -getairportpower en0');
135
+ return { action: a, result: String(r.out || '').trim() };
136
+ }
137
+ if (a === 'sleep') { await run('pmset sleepnow'); return { action: a, result: 'sleeping' }; }
138
+ if (a === 'lock') { await run('pmset displaysleepnow'); return { action: a, result: 'display asleep' }; }
139
+ if (a === 'wallpaper') {
140
+ if (!v) throw new Error('wallpaper 需要图片绝对路径');
141
+ await run(`osascript -e 'tell application "System Events" to set picture of every desktop to ${JSON.stringify(v)}'`);
142
+ return { action: a, result: v };
143
+ }
144
+ if (a === 'shortcut') {
145
+ if (!v) throw new Error('shortcut 需要名称');
146
+ const r = await run(`shortcuts run ${JSON.stringify(v)}`, 30000);
147
+ return { action: a, result: r.code === 0 ? `ran ${v}` : String(r.out || 'failed').slice(0, 400) };
148
+ }
149
+ if (a === 'shortcuts') { const r = await run('shortcuts list'); return { action: a, result: String(r.out || '').trim() }; }
150
+ if (a === 'info') {
151
+ const [bt, dsk, mem, disp] = await Promise.all([
152
+ run('pmset -g batt'), run('df -h /'), run('vm_stat'), run('system_profiler SPDisplaysDataType'),
153
+ ]);
154
+ return { action: a, result: { battery: String(bt.out || '').trim().split('\n').slice(0, 2).join(' '), disk: String(dsk.out || '').trim().split('\n').slice(-1)[0], displays: String(disp.out || '').split('\n').filter((l) => /Resolution|Display Type/.test(l)).slice(0, 4) } };
155
+ }
156
+ throw new Error(`unknown sysControl action: ${a}`);
157
+ }
158
+
159
+ /** ContextOS answers with plain text tables or a JSON status envelope. */
160
+ /** Fixed-width table: use header column offsets, not a 2+ space split. */
161
+ function binaryName() {
162
+ const osName = process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux';
163
+ const arch = process.arch === 'arm64' ? 'arm64' : process.arch === 'ia32' ? '386' : 'amd64';
164
+ return `ice.${osName}.${arch}`;
165
+ }
166
+
167
+ async function findBinary(root) {
168
+ const candidates = [
169
+ process.env.CTX_DEVOPS_BINARY,
170
+ path.join(root, 'usr', 'publish', binaryName()),
171
+ path.join(root, 'bin', 'ice.bin'),
172
+ ].filter(Boolean);
173
+ for (const candidate of candidates) {
174
+ try {
175
+ const st = await fs.stat(candidate);
176
+ if (st.isFile() && st.size > 5 * 1024 * 1024) return candidate;
177
+ } catch {
178
+ /* keep looking */
179
+ }
180
+ }
181
+ throw new Error(`no usable ice.bin; expected ${path.join(root, 'usr', 'publish', binaryName())}`);
182
+ }
183
+
184
+ function launchArgs(port, parent) {
185
+ const a = ['forever', 'start'];
186
+ if (parent) a.push('dev', parent);
187
+ a.push('port', String(port));
188
+ return a;
189
+ }
190
+
191
+ async function writeLauncherMeta(node, source) {
192
+ await fs.mkdir(path.join(node.dir, 'var', 'log'), { recursive: true });
193
+ const cmd = ['./bin/ice.bin', ...launchArgs(node.port, node.parent || '')].join(' ');
194
+ const meta = {
195
+ installDir: node.dir,
196
+ port: node.port,
197
+ parentURL: node.parent || '',
198
+ platform: platformName(),
199
+ source,
200
+ command: cmd,
201
+ createdAt: new Date().toISOString(),
202
+ };
203
+ await fs.writeFile(path.join(node.dir, '.ctx-deploy.json'), JSON.stringify(meta, null, 2));
204
+ await fs.writeFile(
205
+ path.join(node.dir, 'start.sh'),
206
+ `#!/bin/sh\ncd "$(dirname "$0")" || exit 1\nexec ${cmd}\n`,
207
+ { mode: 0o755 },
208
+ );
209
+ await fs.writeFile(
210
+ path.join(node.dir, 'stop.sh'),
211
+ `#!/bin/sh\nPORT=$(cat var/log/ice.port 2>/dev/null)\n[ -z "$PORT" ] && PORT="${node.port}"\ncurl -s -m 5 "http://localhost:$PORT/c/quit"\necho\n`,
212
+ { mode: 0o755 },
213
+ );
214
+ }
215
+
216
+ async function saveRecord(node, source) {
217
+ const registry = await readRegistry();
218
+ const idx = registry.findIndex((r) => r.dir === node.dir);
219
+ const rec = {
220
+ dir: node.dir,
221
+ name: node.name || path.basename(node.dir),
222
+ port: node.port,
223
+ parent: node.parent || '',
224
+ platform: node.platform || platformName(),
225
+ source,
226
+ createdAt: new Date().toISOString(),
227
+ };
228
+ if (idx >= 0) registry[idx] = rec;
229
+ else registry.push(rec);
230
+ await writeRegistry(registry);
231
+ }
232
+
233
+
234
+ async function targetOfBinary(file) {
235
+ const fd = fsp.openSync(file, 'r');
236
+ const head = Buffer.alloc(4096);
237
+ const n = fsp.readSync(fd, head, 0, 4096, 0);
238
+ fsp.closeSync(fd);
239
+ const b = head.subarray(0, n);
240
+ if (n < 20) return null;
241
+ if (b[0] === 0x7f && b[1] === 0x45 && b[2] === 0x4c && b[3] === 0x46) {
242
+ const machine = b.readUInt16LE(18);
243
+ const arch = { 0xb7: 'arm64', 0x3e: 'amd64', 0x28: 'arm', 0x03: '386' }[machine] || 'unknown';
244
+ return `linux.${arch}`;
245
+ }
246
+ const le32 = b.readUInt32LE(0);
247
+ const be32 = b.readUInt32BE(0);
248
+ if (le32 === 0xfeedfacf || le32 === 0xfeedface || be32 === 0xfeedfacf || be32 === 0xfeedface) {
249
+ const cpu = le32 === 0xfeedfacf || le32 === 0xfeedface ? b.readUInt32LE(4) : b.readUInt32BE(4);
250
+ const arch = { 0x0100000c: 'arm64', 0x01000007: 'amd64', 12: 'arm', 7: '386' }[cpu] || 'unknown';
251
+ return `darwin.${arch}`;
252
+ }
253
+ if (be32 === 0xcafebabe || le32 === 0xcafebabe) return 'darwin.universal';
254
+ return null;
255
+ }
256
+
257
+ async function downloadBinary(dir, parent) {
258
+ const file = binaryName();
259
+ const bases = [parent, 'https://shylinux.com'].map((u) => (u || '').replace(/\/$/, '')).filter(Boolean);
260
+ const urls = [...new Set(bases.map((b) => `${b}/publish/${file}`))];
261
+ let last;
262
+ for (const url of urls) {
263
+ try {
264
+ const res = await fetch(url, { signal: AbortSignal.timeout(60000) });
265
+ if (!res.ok) { last = `HTTP ${res.status}`; continue; }
266
+ const buf = Buffer.from(await res.arrayBuffer());
267
+ if (buf.length < 1024 * 1024) { last = `too small (${buf.length}B)`; continue; }
268
+ await fs.writeFile(path.join(dir, 'bin', 'ice.bin'), buf, { mode: 0o755 });
269
+ return { url, size: buf.length };
270
+ } catch (e) {
271
+ last = String(e?.message || e);
272
+ }
273
+ }
274
+ throw new Error(`download failed (${last}); tried ${urls.join(', ')}`);
275
+ }
276
+
277
+ export async function deployNode({ port = 9188, name = '', mode = 'auto', parent = '', repo = '', sourceBin = '', dir: installDir = '' } = {}) {
278
+ port = Number(port);
279
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('port must be 1024..65535');
280
+ if (!(await portFree(port))) throw new Error(`port ${port} is already in use`);
281
+
282
+ const root = ctxRoot();
283
+ const dir = installDir
284
+ ? path.resolve(String(installDir).replace(/^~(?=$|\/)/, HOME()))
285
+ : path.join(path.dirname(root), `${path.basename(root)}-${port}`);
286
+ if (path.resolve(dir) === path.resolve(root)) throw new Error('refuse to deploy over the running root');
287
+ const existing = (await listNodes()).find((n) => n.port === port && n.dir === dir);
288
+ if (existing?.live) return { ...existing, reused: true };
289
+ if (fsp.existsSync(path.join(dir, 'bin', 'ice.bin'))) {
290
+ throw new Error(`directory ${dir} already has bin/ice.bin; choose another port or remove it yourself`);
291
+ }
292
+
293
+ let sourceTag = '';
294
+ const gitOk = (await runShell('git --version', dir).catch(() => ({ code: 1 }))).code === 0;
295
+ const goOk = (await runShell('go version', dir).catch(() => ({ code: 1 }))).code === 0;
296
+ const hasRepo = fsp.existsSync(path.join(dir, '.git'));
297
+ const canBuild = hasRepo && goOk && fsp.existsSync(path.join(dir, 'etc', 'miss.sh'));
298
+ const cloneRepo = async () => {
299
+ const targetRepo = repo || 'https://shylinux.com/x/ContextOS';
300
+ if (fsp.existsSync(dir) && !hasRepo) {
301
+ const entries = fsp.readdirSync(dir);
302
+ if (entries.length) throw new Error(`directory ${dir} exists and is not a git repo`);
303
+ }
304
+ const target = `'${dir.replace(/'/g, "'\\''")}'`;
305
+ const r = await runShell(`git clone --progress ${JSON.stringify(targetRepo)} ${target}`, path.dirname(dir), { timeoutMs: 300000 });
306
+ if (r.code !== 0) throw new Error(`git clone failed (${r.code}): ${r.err.slice(-400)}`);
307
+ };
308
+ if (mode === 'auto' || mode === 'source') {
309
+ if (!hasRepo) await cloneRepo();
310
+ if (!fsp.existsSync(path.join(dir, 'etc', 'miss.sh'))) throw new Error('clone done but etc/miss.sh missing — not a ContextOS repo');
311
+ }
312
+ if (mode === 'source') {
313
+ if (!goOk) throw new Error('go is required for source mode');
314
+ const b = await runShell('source ./etc/miss.sh', dir, { timeoutMs: 600000 });
315
+ if (b.code !== 0) throw new Error(`build failed (${b.code}): ${b.err.slice(-500)}`);
316
+ if (!fsp.existsSync(path.join(dir, 'bin', 'ice.bin'))) throw new Error('build produced no bin/ice.bin');
317
+ sourceTag = `git:${repo || 'default'}+build`;
318
+ } else if (mode === 'auto' && canBuild) {
319
+ const b = await runShell('source ./etc/miss.sh', dir, { timeoutMs: 600000 });
320
+ if (b.code === 0 && fsp.existsSync(path.join(dir, 'bin', 'ice.bin'))) sourceTag = `git:${repo || 'default'}+build`;
321
+ else sourceTag = '';
322
+ }
323
+ if (!sourceTag && mode !== 'source') {
324
+ await fs.mkdir(path.join(dir, 'bin'), { recursive: true });
325
+ let copied = '';
326
+ try {
327
+ const source = sourceBin || (await findBinary(root));
328
+ await fs.copyFile(source, path.join(dir, 'bin', 'ice.bin'));
329
+ await fs.chmod(path.join(dir, 'bin', 'ice.bin'), 0o755);
330
+ copied = `copy:${source}`;
331
+ } catch { /* try other nodes, then download */ }
332
+ if (!copied) {
333
+ const other = (await listNodes()).find((n) => n.port !== port && n.bin && fsp.existsSync(n.bin));
334
+ if (other) {
335
+ await fs.copyFile(other.bin, path.join(dir, 'bin', 'ice.bin'));
336
+ await fs.chmod(path.join(dir, 'bin', 'ice.bin'), 0o755);
337
+ copied = `copy:${other.dir}`;
338
+ }
339
+ }
340
+ if (!copied) {
341
+ const dl = await downloadBinary(dir, parent);
342
+ copied = `download:${dl.url}`;
343
+ }
344
+ const actual = await targetOfBinary(path.join(dir, 'bin', 'ice.bin'));
345
+ if (actual && !actual.endsWith('.universal') && actual !== platformName()) throw new Error(`platform mismatch: want ${platformName()}, got ${actual}`);
346
+ sourceTag = copied;
347
+ }
348
+ const nodeName = name ? (/^ContextOS/i.test(name) ? name : `ContextOS-${name}`) : `ContextOS-${port}`;
349
+ const node = { dir, bin: path.join(dir, 'bin', 'ice.bin'), name: nodeName, port, parent };
350
+ await writeLauncherMeta(node, sourceTag);
351
+ const started = await startNode({ port });
352
+ await saveRecord(node, sourceTag);
353
+ return { ...node, ...started, source: sourceTag, mode };
354
+ }
355
+
356
+ export async function startNode({ port } = {}) {
357
+ const node = (await listNodes()).find((n) => n.port === Number(port));
358
+ if (!node) throw new Error(`node ${port} not found (deploy or register it first)`);
359
+ if (node.state === 'running') return { port: node.port, state: 'running', note: 'already running' };
360
+ if (!node.hasBin && !(await binExists(node))) throw new Error('no bin/ice.bin — deploy first');
361
+
362
+ await fs.mkdir(path.join(node.dir, 'var', 'log'), { recursive: true });
363
+ const logPath = path.join(node.dir, 'var', 'log', 'deploy.log');
364
+ const out = fsp.openSync(logPath, 'a');
365
+ const child = spawn(path.join(node.dir, 'bin', 'ice.bin'), launchArgs(node.port, node.parent), {
366
+ cwd: node.dir,
367
+ detached: true,
368
+ stdio: ['ignore', out, out],
369
+ env: { ...process.env },
370
+ });
371
+ child.unref();
372
+ fsp.closeSync(out);
373
+
374
+ try {
375
+ await waitFor(async () => (await probe(node.port, 1000)).live, { timeout: 75000, interval: 1000, label: `node ${node.port} up` });
376
+ } catch (e) {
377
+ throw new Error(`node ${node.port} not ready: ${String(e?.message || e)} — log ${logPath}`);
378
+ }
379
+ return { port: node.port, state: 'running', dir: node.dir, url: `http://localhost:${node.port}/`, pid: child.pid };
380
+ }
381
+
382
+ export async function stopNode({ port } = {}) {
383
+ const node = (await listNodes()).find((n) => n.port === Number(port));
384
+ if (!node) throw new Error(`node ${port} not found`);
385
+ if (node.state !== 'running') return { port: Number(port), state: 'stopped', note: 'already stopped' };
386
+ try {
387
+ await http(Number(port), '/c/quit', { timeout: 3000 });
388
+ } catch {
389
+ /* quit endpoint may 405/close; fall through to pid */
390
+ }
391
+ try {
392
+ await waitFor(async () => !(await probe(Number(port), 1000)).live, { timeout: 20000, interval: 500, label: `node ${port} down` });
393
+ } catch {
394
+ const pid = Number((await fs.readFile(path.join(node.dir, 'var', 'log', 'ice.pid'), 'utf8')).trim());
395
+ if (pid > 1) { try { process.kill(pid, 'SIGTERM'); } catch {} }
396
+ await waitFor(async () => !(await probe(Number(port), 1000)).live, { timeout: 15000, interval: 500, label: `node ${port} down (pid)` });
397
+ }
398
+ return { port: Number(port), state: 'stopped' };
399
+ }
400
+
401
+ export async function openNode({ port } = {}) {
402
+ const node = (await listNodes()).find((n) => n.port === Number(port));
403
+ if (!node) throw new Error(`node ${port} not found`);
404
+ platformOpenBrowser(`http://localhost:${node.port}/`);
405
+ return { opened: `http://localhost:${node.port}/` };
406
+ }
407
+
408
+ export async function openSpace({ node, space } = {}) {
409
+ platformOpenBrowser(`http://localhost:${Number(node)}/s/${encodeURIComponent(space)}/`);
410
+ return { opened: `http://localhost:${Number(node)}/s/${space}/` };
411
+ }
412
+
413
+ export async function openTerminalAt(dir) {
414
+ const target = fsp.existsSync(dir) ? dir : path.join(dir);
415
+ platformOpenTerminal(target);
416
+ return { opened: target };
417
+ }
418
+
419
+ export async function nodeTerminal({ port } = {}) {
420
+ const node = (await listNodes()).find((n) => n.port === Number(port));
421
+ if (!node) throw new Error(`node ${port} not found`);
422
+ return openTerminalAt(node.dir);
423
+ }
424
+
425
+ export async function spaceTerminal({ node, space } = {}) {
426
+ const n = (await listNodes()).find((x) => x.port === Number(node));
427
+ if (!n) throw new Error(`node ${node} not found`);
428
+ return openTerminalAt(path.join(n.dir, 'usr', 'local', 'work', space));
429
+ }
430
+
431
+ export async function deleteNode({ port } = {}) {
432
+ const n = (await listNodes()).find((x) => x.port === Number(port));
433
+ if (!n) throw new Error(`node ${port} not found`);
434
+ const root = path.resolve(ctxRoot());
435
+ const dir = path.resolve(n.dir);
436
+ if (dir === root || !dir.startsWith(path.dirname(root) + path.sep)) {
437
+ throw new Error(`refuse to delete ${dir}: not a sibling node directory`);
438
+ }
439
+ try { await stopNode({ port: n.port }); } catch { /* already stopped */ }
440
+ await fs.rm(dir, { recursive: true, force: true });
441
+ const registry = (await readRegistry()).filter((x) => path.resolve(x.dir || '') !== dir && Number(x.port) !== n.port);
442
+ await writeRegistry(registry);
443
+ return { port: n.port, dir, deleted: true };
444
+ }
445
+
446
+ export async function deleteSpace({ node, space } = {}) {
447
+ const n = (await listNodes()).find((x) => x.port === Number(node));
448
+ if (!n) throw new Error(`node ${node} not found`);
449
+ const base = path.resolve(n.dir, 'usr', 'local', 'work');
450
+ const dir = path.resolve(base, space);
451
+ if (!dir.startsWith(base + path.sep)) throw new Error('space path escapes work dir');
452
+ try { await stopSpace({ node, space }); } catch { /* already stopped */ }
453
+ await http(node, `/c/dream/action/remove?name=${encode(space)}`, { method: 'DELETE', timeout: 30000 });
454
+ await fs.rm(dir, { recursive: true, force: true });
455
+ return { node: Number(node), space, dir, deleted: true };
456
+ }
457
+
458
+ const OFFICIAL_SPACES = [
459
+ { key: 'community', name: '云社区', prefix: '20240724', repo: 'https://shylinux.com/x/community' },
460
+ { key: 'enterprise', name: '云办公', prefix: '20240724', repo: 'https://shylinux.com/x/enterprise' },
461
+ { key: 'operation', name: '云研发', prefix: '20240903', repo: 'https://shylinux.com/x/operation' },
462
+ { key: 'knowledge', name: '云知识', prefix: '20250520', repo: 'https://shylinux.com/x/knowledge' },
463
+ { key: 'education', name: '云教育', prefix: '20240724', repo: 'https://shylinux.com/x/education' },
464
+ { key: 'healthy', name: '云健康', prefix: '20250520', repo: 'https://shylinux.com/x/healthy' },
465
+ { key: 'tourism', name: '云文旅', prefix: '20250520', repo: 'https://shylinux.com/x/tourism' },
466
+ { key: 'charity', name: '云公益', prefix: '20250520', repo: 'https://shylinux.com/x/charity' },
467
+ { key: 'industry', name: '云工业', prefix: '20250520', repo: 'https://shylinux.com/x/industry' },
468
+ { key: 'agriculture', name: '云农业', prefix: '20250520', repo: 'https://shylinux.com/x/agriculture' },
469
+ ];
470
+
471
+ function officialKey(value) {
472
+ const text = String(value || '').trim();
473
+ const found = OFFICIAL_SPACES.find((x) => x.key === text || x.name === text);
474
+ return found?.key || '';
475
+ }
476
+
477
+ export async function deployOfficialSpaces({ node, keys = [] } = {}) {
478
+ const wanted = new Set((keys || []).map(officialKey).filter(Boolean));
479
+ if (!wanted.size) throw new Error('choose at least one official space');
480
+ const existing = await listSpaces({ node });
481
+ const out = [];
482
+ for (const item of OFFICIAL_SPACES.filter((x) => wanted.has(x.key))) {
483
+ // Standard spaces keep their canonical date prefix (20240724/20240903/20250520),
484
+ // not today's date; reuse only the exact prefixed name.
485
+ const fullName = `${item.prefix}-${item.key}`;
486
+ const found = existing.find((s) => s.name === fullName);
487
+ if (found) {
488
+ let repaired = false;
489
+ let warning = '';
490
+ try {
491
+ const nodeBin = path.join((await listNodes()).find((x) => x.port === Number(node))?.dir || '', 'bin', 'ice.bin');
492
+ const spaceBin = path.join((await listNodes()).find((x) => x.port === Number(node))?.dir || '', 'usr', 'local', 'work', found.name, 'bin', 'ice.bin');
493
+ const same = nodeBin && fsp.existsSync(nodeBin) && fsp.existsSync(spaceBin) && fsp.statSync(nodeBin).size === fsp.statSync(spaceBin).size;
494
+ if (!fsp.existsSync(spaceBin) || same) { await buildSpace({ node, space: found.name }); repaired = true; }
495
+ } catch (e) { warning = String(e?.message || e); }
496
+ const warm = await warmSpace({ node, space: found.name });
497
+ out.push({ ...item, space: found.name, reused: true, repaired, warning, warm });
498
+ continue;
499
+ }
500
+ try {
501
+ const created = await createSpace({ node, name: fullName, repos: item.repo });
502
+ let built = false;
503
+ let warning = '';
504
+ try { await buildSpace({ node, space: created.name }); built = true; }
505
+ catch (e) { warning = String(e?.message || e); }
506
+ const warm = await warmSpace({ node, space: created.name });
507
+ out.push({ ...item, space: created.name, reused: false, built, warning, warm });
508
+ } catch (e) {
509
+ out.push({ ...item, error: String(e?.message || e) });
510
+ }
511
+ }
512
+ return out;
513
+ }
514
+
515
+ export async function dockerDeploy({ port = 9188, dir = '', image = '', name = '' } = {}) {
516
+ const p = Number(port) || 9188;
517
+ const img = String(image || 'shylinux/contextos:latest');
518
+ const check = await runShell('docker version --format "{{.Server.Version}}"', undefined, { timeoutMs: 15000 });
519
+ if (check.code !== 0) throw new Error(`Docker 不可用:${(check.err || check.out || '').trim().slice(0, 160)}`);
520
+ const cname = String(name || `contextos-${p}`);
521
+ await runShell(`docker rm -f ${cname}`, undefined, { timeoutMs: 30000 });
522
+ const hostDir = dir ? `-v ${JSON.stringify(String(dir))}:/data` : '';
523
+ const r = await runShell(`docker run -d --name ${cname} -p ${p}:${p} ${hostDir} ${JSON.stringify(img)}`, undefined, { timeoutMs: 300000 });
524
+ if (r.code !== 0) throw new Error(`容器启动失败:${(r.err || r.out || '').trim().slice(0, 240)}`);
525
+ const id = (r.out || '').trim().split('\n').pop();
526
+ const deadline = Date.now() + 60000;
527
+ let ok = false;
528
+ while (Date.now() < deadline) {
529
+ try {
530
+ const res = await fetch(`http://127.0.0.1:${p}/c/runtime`, { signal: AbortSignal.timeout(3000) });
531
+ if (res.ok) { ok = true; break; }
532
+ } catch { /* retry */ }
533
+ await new Promise((x) => setTimeout(x, 1500));
534
+ }
535
+ if (!ok) throw new Error(`容器已启动(${id.slice(0, 12)}),但 :${p} 的 /c/runtime 未就绪,请检查镜像入口`);
536
+ return { container: cname, id, port: p, image: img };
537
+ }
538
+
539
+ /** 远程部署:通过 ssh 在远端执行 docker run。 */
540
+ export async function remoteDeploy({ host = '', user = '', sshPort = 22, port = 9188, dir = '', image = '' } = {}) {
541
+ if (!host) throw new Error('远程主机不能为空');
542
+ const p = Number(port) || 9188;
543
+ const img = String(image || 'shylinux/contextos:latest');
544
+ const target = `${user ? `${user}@` : ''}${host}`;
545
+ const script = `docker rm -f contextos-${p} >/dev/null 2>&1; docker run -d --name contextos-${p} -p ${p}:${p} ${dir ? `-v ${JSON.stringify(String(dir))}:/data` : ''} ${JSON.stringify(img)}`;
546
+ const ssh = `ssh -p ${Number(sshPort) || 22} -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=8 ${JSON.stringify(target)} ${JSON.stringify(script)}`;
547
+ const r = await runShell(ssh, undefined, { timeoutMs: 120000 });
548
+ if (r.code !== 0) throw new Error(`远程部署失败:${(r.err || r.out || '').trim().slice(0, 240)}`);
549
+ return { host, port: p, target, id: (r.out || '').trim() };
550
+ }
551
+
552
+ /** 远程连接:登录远端节点换 sessid。 */
553
+ export async function remoteLogin({ url = '', user = '', pass = '' } = {}) {
554
+ const base = String(url || '').trim().replace(/\/+$/, '');
555
+ if (!/^https?:\/\//.test(base)) throw new Error('远程地址必须以 http:// 或 https:// 开头');
556
+ const attempts = [];
557
+ const pick = (headers, text) => {
558
+ const cookie = headers.get('set-cookie') || '';
559
+ const m = /sessid=([^;]+)/.exec(cookie);
560
+ if (m) return m[1];
561
+ try { const j = JSON.parse(text); return j.sessid || (j.data && j.data.sessid) || ''; } catch { return ''; }
562
+ };
563
+ try {
564
+ const r = await fetch(`${base}/c/aaa.user/action/login?name=${encodeURIComponent(user)}&pass=${encodeURIComponent(pass)}`, { method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(12000) });
565
+ const text = await r.text();
566
+ const sid = pick(r.headers, text);
567
+ if (sid) return { url: base, sessid: sid, user, via: 'login' };
568
+ attempts.push(`login HTTP ${r.status}`);
569
+ } catch (e) { attempts.push(`login ${e.message}`); }
570
+ try {
571
+ const r = await fetch(`${base}/c/aaa.sess/action/create/${encodeURIComponent(user || 'shy')}`, { method: 'PUT', signal: AbortSignal.timeout(12000) });
572
+ const text = await r.text();
573
+ const sid = pick(r.headers, text) || String(text || '').trim().split('\n').pop();
574
+ if (sid && sid.length > 8) return { url: base, sessid: sid, user, via: 'sess' };
575
+ attempts.push(`sess HTTP ${r.status}`);
576
+ } catch (e) { attempts.push(`sess ${e.message}`); }
577
+ throw new Error(`登录失败:${attempts.join(' / ')}`);
578
+ }
579
+
580
+ export async function runPipeline({ port = 9188, space = 'devops_demo', app = 'note' } = {}) {
581
+ const steps = [];
582
+ const step = async (name, fn) => {
583
+ const started = Date.now();
584
+ try {
585
+ const value = await fn();
586
+ steps.push({ step: name, ok: true, ms: Date.now() - started, value });
587
+ return value;
588
+ } catch (error) {
589
+ steps.push({ step: name, ok: false, ms: Date.now() - started, error: String(error?.message || error) });
590
+ throw Object.assign(new Error(`${name}: ${error?.message || error}`), { steps });
591
+ }
592
+ };
593
+
594
+ const node = await step('deploy-node', () => deployNode({ port }));
595
+ const created = await step('create-space', () => createSpace({ node: port, name: space }));
596
+ const createdApp = await step('create-app', () => createApp({ node: port, space: created.name, name: app }));
597
+ const written = await step('write-data', () =>
598
+ writeData({
599
+ node: port,
600
+ space: created.name,
601
+ command: createdApp.command,
602
+ fields: { user_uid: 'root', title: 'ContextOS DevOps', content: `pipeline ${new Date().toISOString()}` },
603
+ }),
604
+ );
605
+ await step('read-back', async () => {
606
+ const data = await readData({ node: port, space: created.name, command: createdApp.command });
607
+ const titles = data.rows.map((row) => row.title || '').join(' ');
608
+ if (!data.rows.length || !/ContextOS DevOps/.test(titles)) {
609
+ throw new Error(`read-back did not contain the written record (count ${data.count || 'empty'})`);
610
+ }
611
+ return data;
612
+ });
613
+ return {
614
+ ok: true,
615
+ node: port,
616
+ space: created.name,
617
+ app: createdApp,
618
+ count: written.count,
619
+ steps,
620
+ };
621
+ }
622
+
623
+
624
+
625
+ // ── DSH intent layer ─────────────────────────────────────────────
626
+ // The chat op is a thin intent parser: DSH llm.stream turns one natural
627
+ // language line into {reply, action}; execution stays in the explicit ops.