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
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="cdx" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#2f7cf6"/>
|
|
5
|
+
<stop offset="1" stop-color="#12b8a6"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="3" y="3" width="58" height="58" rx="14" fill="#0f172a"/>
|
|
9
|
+
<path d="M32 13 15 22v20l17 9 17-9V22L32 13Z" fill="none" stroke="url(#cdx)" stroke-width="3" stroke-linejoin="round"/>
|
|
10
|
+
<path d="M32 32 15 22M32 32l17-10M32 32v19" stroke="url(#cdx)" stroke-width="2" opacity=".7"/>
|
|
11
|
+
<circle cx="32" cy="32" r="4.5" fill="url(#cdx)"/>
|
|
12
|
+
<circle cx="15" cy="22" r="3" fill="#12b8a6"/>
|
|
13
|
+
<circle cx="49" cy="22" r="3" fill="#2f7cf6"/>
|
|
14
|
+
<circle cx="32" cy="51" r="3" fill="#7dd3fc"/>
|
|
15
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { ctxRoot } from './lib/base/env.js';
|
|
5
|
+
import { listNodes } from './lib/base/registry.js';
|
|
6
|
+
import { writeData, readData } from './lib/core/data.js';
|
|
7
|
+
import { createApp } from './lib/core/apps.js';
|
|
8
|
+
import { ensureUser } from './lib/core/users.js';
|
|
9
|
+
import { setLlm } from './lib/core/copilot.js';
|
|
10
|
+
import { deployNode, startNode, stopNode, openNode, openSpace, openTerminalAt, nodeTerminal, spaceTerminal, runPipeline } from './lib/core/nodes.js';
|
|
11
|
+
import { migrateStateOnce } from './lib/base/state.js';
|
|
12
|
+
import { makeStatic } from './lib/misc/static.js';
|
|
13
|
+
import { terminalUpgrade } from './lib/core/terminal2.js';
|
|
14
|
+
import { startSpace, stopSpace, listSpaces, createSpace } from './lib/core/spaces.js';
|
|
15
|
+
import { HTTP_DEBUG, http } from './lib/base/http.js';
|
|
16
|
+
import { OPS } from './lib/core/ops.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* ContextOS DevOps core.
|
|
20
|
+
*
|
|
21
|
+
* Every mutation goes through ContextOS' own HTTP API (SPEC-CICD.md §0.2,
|
|
22
|
+
* USER-API.md §7/§8) or through the documented node bootstrap
|
|
23
|
+
* (`bin/ice.bin serve start`, SPEC-CICD.md §2). No shell wrapping, no private
|
|
24
|
+
* storage formats; node directories stay siblings of the main checkout.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
/** 插件用到的数据文件(JSON)清单。 */
|
|
29
|
+
/** 读取插件数据文件内容(仅限 state 目录内、单文件 512KB 以内)。 */
|
|
30
|
+
/** 把旧版状态目录迁移到新目录(一次性)。 */
|
|
31
|
+
const ROUTE = '/dsh-contextos-devops/api';
|
|
32
|
+
|
|
33
|
+
const text = (value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }];
|
|
34
|
+
const output = {
|
|
35
|
+
schema: { type: 'object', properties: {}, additionalProperties: true },
|
|
36
|
+
render: (_args, value) => text(value),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const tools = [
|
|
40
|
+
{
|
|
41
|
+
name: 'contextos_deploy_node',
|
|
42
|
+
description:
|
|
43
|
+
'Deploy a local ContextOS service node. mode=copy (default, copy a local bin/ice.bin), mode=download (fetch ice.<os>.<arch> from a /publish source), mode=source (git clone + source etc/miss.sh build). Then bin/ice.bin forever start port <p>, and verify /c/runtime + /v/index.css.',
|
|
44
|
+
parameters: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
port: { type: 'number', description: 'TCP port for the new node (default 9188).' },
|
|
48
|
+
name: { type: 'string', description: 'Optional label for the node registry.' },
|
|
49
|
+
mode: { type: 'string', description: "Deploy mode: 'copy' | 'download' | 'source' (default copy)." },
|
|
50
|
+
parent: { type: 'string', description: "Parent node URL for download source and 'dev <parent>' launch." },
|
|
51
|
+
repo: { type: 'string', description: 'Git repo URL for source mode.' },
|
|
52
|
+
sourceBin: { type: 'string', description: 'Local bin/ice.bin path for copy mode.' },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
async execute(args) {
|
|
56
|
+
return deployNode({
|
|
57
|
+
port: args?.port ?? 9188,
|
|
58
|
+
name: args?.name ?? '',
|
|
59
|
+
mode: args?.mode ?? 'copy',
|
|
60
|
+
parent: args?.parent ?? '',
|
|
61
|
+
repo: args?.repo ?? '',
|
|
62
|
+
sourceBin: args?.sourceBin ?? '',
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'contextos_list_nodes',
|
|
68
|
+
description: 'List ContextOS nodes managed by this plugin with live health (process/HTTP/static assets) and their version.',
|
|
69
|
+
parameters: { type: 'object', properties: {} },
|
|
70
|
+
async execute() {
|
|
71
|
+
return { ctxRoot: ctxRoot(), nodes: await listNodes() };
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: 'contextos_stop_node',
|
|
76
|
+
description: 'Stop a ContextOS node managed by this plugin by its own var/log/ice.pid; never kills other processes.',
|
|
77
|
+
parameters: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: { port: { type: 'number', description: 'Port of the managed node.' } },
|
|
80
|
+
required: ['port'],
|
|
81
|
+
},
|
|
82
|
+
async execute(args) {
|
|
83
|
+
return stopNode({ port: args.port });
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'contextos_start_node',
|
|
88
|
+
description: "Start a stopped ContextOS node: ./bin/ice.bin forever start port <p> in its directory, then wait for /c/runtime.",
|
|
89
|
+
parameters: { type: 'object', properties: { port: { type: 'number', description: 'Node port.' } }, required: ['port'] },
|
|
90
|
+
async execute(args) { return startNode({ port: args.port }); },
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'contextos_open_node',
|
|
94
|
+
description: "Open a node's home page (http://localhost:<port>/) in the system browser.",
|
|
95
|
+
parameters: { type: 'object', properties: { port: { type: 'number', description: 'Node port.' } }, required: ['port'] },
|
|
96
|
+
async execute(args) { return openNode({ port: args.port }); },
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'contextos_node_terminal',
|
|
100
|
+
description: "Open a macOS Terminal window at the node's directory.",
|
|
101
|
+
parameters: { type: 'object', properties: { port: { type: 'number', description: 'Node port.' } }, required: ['port'] },
|
|
102
|
+
async execute(args) { return nodeTerminal({ port: args.port }); },
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'contextos_space_start',
|
|
106
|
+
description: 'Start a project space: POST /c/dream/action/start?name=<space>.',
|
|
107
|
+
parameters: { type: 'object', properties: { node: { type: 'number' }, space: { type: 'string' } }, required: ['node', 'space'] },
|
|
108
|
+
async execute(args) { return startSpace({ node: args.node, space: args.space }); },
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'contextos_space_stop',
|
|
112
|
+
description: 'Stop a project space: POST /c/dream/action/stop?name=<space>.',
|
|
113
|
+
parameters: { type: 'object', properties: { node: { type: 'number' }, space: { type: 'string' } }, required: ['node', 'space'] },
|
|
114
|
+
async execute(args) { return stopSpace({ node: args.node, space: args.space }); },
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'contextos_space_open',
|
|
118
|
+
description: "Open a project space's home page (http://localhost:<node>/s/<space>/) in the system browser.",
|
|
119
|
+
parameters: { type: 'object', properties: { node: { type: 'number' }, space: { type: 'string' } }, required: ['node', 'space'] },
|
|
120
|
+
async execute(args) { return openSpace({ node: args.node, space: args.space }); },
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'contextos_space_terminal',
|
|
124
|
+
description: "Open a macOS Terminal window at the space's directory (<node>/usr/local/work/<space>).",
|
|
125
|
+
parameters: { type: 'object', properties: { node: { type: 'number' }, space: { type: 'string' } }, required: ['node', 'space'] },
|
|
126
|
+
async execute(args) { return spaceTerminal({ node: args.node, space: args.space }); },
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'contextos_list_spaces',
|
|
130
|
+
description: 'List project spaces on a node (GET /c/dream): full dated name, status, module, version.',
|
|
131
|
+
parameters: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
properties: { node: { type: 'number', description: 'Node port.' } },
|
|
134
|
+
required: ['node'],
|
|
135
|
+
},
|
|
136
|
+
async execute(args) {
|
|
137
|
+
return { node: args.node, spaces: await listSpaces({ node: args.node }) };
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'contextos_create_space',
|
|
142
|
+
description:
|
|
143
|
+
'Create a project space with PUT /c/dream/action/create, wait for the dated name to appear in /c/dream, and wait until the space serves /c/runtime.',
|
|
144
|
+
parameters: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
node: { type: 'number', description: 'Node port.' },
|
|
148
|
+
name: { type: 'string', description: 'Short space name, [a-zA-Z0-9_-]+.' },
|
|
149
|
+
},
|
|
150
|
+
required: ['node', 'name'],
|
|
151
|
+
},
|
|
152
|
+
async execute(args) {
|
|
153
|
+
return createSpace({ node: args.node, name: args.name });
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'contextos_create_app',
|
|
158
|
+
description:
|
|
159
|
+
'Create a real ContextOS app inside a space: git init, vimer module scaffold (Hash), save a CRUD command, compile, and verify the command answers. Returns the command path used for data.',
|
|
160
|
+
parameters: {
|
|
161
|
+
type: 'object',
|
|
162
|
+
properties: {
|
|
163
|
+
node: { type: 'number', description: 'Node port.' },
|
|
164
|
+
space: { type: 'string', description: 'Full dated space name (see contextos_list_spaces).' },
|
|
165
|
+
name: { type: 'string', description: 'App name, [a-zA-Z][a-zA-Z0-9_]*.' },
|
|
166
|
+
zone: { type: 'string', description: 'Module zone / package directory (default devops).' },
|
|
167
|
+
help: { type: 'string', description: 'Command help text.' },
|
|
168
|
+
},
|
|
169
|
+
required: ['node', 'space', 'name'],
|
|
170
|
+
},
|
|
171
|
+
async execute(args) {
|
|
172
|
+
return createApp({
|
|
173
|
+
node: args.node,
|
|
174
|
+
space: args.space,
|
|
175
|
+
name: args.name,
|
|
176
|
+
zone: args.zone ?? 'devops',
|
|
177
|
+
help: args.help ?? '',
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: 'contextos_write_data',
|
|
183
|
+
description:
|
|
184
|
+
'Write one record through a ContextOS app command with PUT /action/create. Field values must not contain "/" (the URL path separator). Reads the table back and returns the rows.',
|
|
185
|
+
parameters: {
|
|
186
|
+
type: 'object',
|
|
187
|
+
properties: {
|
|
188
|
+
node: { type: 'number', description: 'Node port.' },
|
|
189
|
+
space: { type: 'string', description: 'Full dated space name.' },
|
|
190
|
+
command: { type: 'string', description: 'Command name, e.g. web.chat.devops.note.' },
|
|
191
|
+
fields: { type: 'object', description: 'Field/value pairs, for example {title, content, user_uid}.' },
|
|
192
|
+
},
|
|
193
|
+
required: ['node', 'space', 'command', 'fields'],
|
|
194
|
+
},
|
|
195
|
+
async execute(args) {
|
|
196
|
+
return writeData({ node: args.node, space: args.space, command: args.command, fields: args.fields ?? {} });
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'contextos_read_data',
|
|
201
|
+
description: 'List records of a ContextOS app command (GET /s/<space>/c/<command>) and return the parsed table plus the row count.',
|
|
202
|
+
parameters: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
node: { type: 'number', description: 'Node port.' },
|
|
206
|
+
space: { type: 'string', description: 'Full dated space name.' },
|
|
207
|
+
command: { type: 'string', description: 'Command name.' },
|
|
208
|
+
},
|
|
209
|
+
required: ['node', 'space', 'command'],
|
|
210
|
+
},
|
|
211
|
+
async execute(args) {
|
|
212
|
+
return readData({ node: args.node, space: args.space, command: args.command });
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: 'contextos_pipeline',
|
|
217
|
+
description:
|
|
218
|
+
'Run the end-to-end CI/CD loop: deploy node -> create space -> create app -> write a record -> read it back. Returns each step with status and elapsed time.',
|
|
219
|
+
parameters: {
|
|
220
|
+
type: 'object',
|
|
221
|
+
properties: {
|
|
222
|
+
port: { type: 'number', description: 'Node port (default 9188).' },
|
|
223
|
+
space: { type: 'string', description: 'Short space name (default devops_demo; no "-").' },
|
|
224
|
+
app: { type: 'string', description: 'App name (default note).' },
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
async execute(args) {
|
|
228
|
+
return runPipeline({ port: args?.port ?? 9188, space: args?.space ?? 'devops_demo', app: args?.app ?? 'note' });
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
async function readBody(req) {
|
|
234
|
+
const chunks = [];
|
|
235
|
+
let size = 0;
|
|
236
|
+
for await (const chunk of req) {
|
|
237
|
+
size += chunk.length;
|
|
238
|
+
if (size > 2 * 1024 * 1024) throw new Error('request body too large');
|
|
239
|
+
chunks.push(chunk);
|
|
240
|
+
}
|
|
241
|
+
if (!chunks.length) return {};
|
|
242
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function send(res, status, payload) {
|
|
246
|
+
const calls = HTTP_DEBUG.getStore();
|
|
247
|
+
if (calls && calls.length) payload = { ...payload, debug: calls.slice(-80) };
|
|
248
|
+
const logId = crypto.randomUUID();
|
|
249
|
+
const body = JSON.stringify(payload);
|
|
250
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'log-id': logId });
|
|
251
|
+
res.end(body);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const bodyUid = (body, name) => body[`${name}_uid`] || body[`${name}UID`] || body[`${name}Id`] || '';
|
|
255
|
+
|
|
256
|
+
async function route(req, res) {
|
|
257
|
+
try {
|
|
258
|
+
await migrateStateOnce();
|
|
259
|
+
const incoming = Object.fromEntries(new URL(req.url || '/', 'http://x').searchParams.entries());
|
|
260
|
+
const body = { ...incoming, ...(await readBody(req)) };
|
|
261
|
+
const op = String(body.op || 'health');
|
|
262
|
+
const node = Number(body.node);
|
|
263
|
+
|
|
264
|
+
// origin 需要连接本机端口,拿不到就退回 Host 头:只有这一条留在路由层。
|
|
265
|
+
if (op === 'origin') {
|
|
266
|
+
let host = '';
|
|
267
|
+
try { const a = server && server.address && server.address(); if (a && a.port) host = `${!a.address || a.address === '::' || a.address === '::1' ? '127.0.0.1' : a.address}:${a.port}`; } catch {}
|
|
268
|
+
if (!host) { try { host = String(req.headers.host || ''); } catch {} }
|
|
269
|
+
return send(res, 200, { ok: true, data: { host } });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const entry = OPS[op];
|
|
273
|
+
if (!entry) return send(res, 404, { ok: false, error: `unknown op ${op}` });
|
|
274
|
+
// 节点已启动的操作先确保 DSH Copilot 用户存在(15s 超时,失败不阻断)。
|
|
275
|
+
if (node && !entry.noUser) await ensureUser(node).catch(() => {});
|
|
276
|
+
return send(res, 200, { ok: true, data: await entry.run(body, { node, req }) });
|
|
277
|
+
} catch (error) {
|
|
278
|
+
return send(res, 200, { ok: false, error: String(error?.message || error), steps: error?.steps });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// webServer must be ready before apply, otherwise the plugin registers its
|
|
283
|
+
// tools but silently loses the HTTP route the Web page calls.
|
|
284
|
+
export const inject = ['tools', 'webServer', 'llm'];
|
|
285
|
+
|
|
286
|
+
export function apply(ctx) {
|
|
287
|
+
setLlm(ctx.llm || (ctx.get && ctx.get('llm')) || null);
|
|
288
|
+
for (const tool of tools) {
|
|
289
|
+
ctx.effect(() =>
|
|
290
|
+
ctx.tools.register({
|
|
291
|
+
name: tool.name,
|
|
292
|
+
description: tool.description,
|
|
293
|
+
parameters: tool.parameters,
|
|
294
|
+
output,
|
|
295
|
+
execute: tool.execute,
|
|
296
|
+
isConcurrencySafe: () => false,
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const pluginDir = path.dirname(fileURLToPath(import.meta.url));
|
|
302
|
+
const monacoRoot = process.env.CTX_MONACO_DIR || path.join(ctxRoot(), 'usr', 'macos-app', 'Resources', 'monaco');
|
|
303
|
+
const xtermRoot = path.join(pluginDir, 'vendor', 'xterm');
|
|
304
|
+
const threeRoot = path.join(pluginDir, 'vendor', 'three');
|
|
305
|
+
const leafletRoot = path.join(pluginDir, 'vendor', 'leaflet');
|
|
306
|
+
const assetsRoot = path.join(pluginDir, 'assets');
|
|
307
|
+
|
|
308
|
+
const server = ctx.webServer || ctx.get('webServer');
|
|
309
|
+
if (server) {
|
|
310
|
+
ctx.effect(() => server.registerUpgrade({ path: '/dsh-contextos-devops/terminal', handler: terminalUpgrade }));
|
|
311
|
+
ctx.effect(() =>
|
|
312
|
+
server.register({
|
|
313
|
+
kind: 'exact',
|
|
314
|
+
path: ROUTE,
|
|
315
|
+
handler: (req, res) => {
|
|
316
|
+
HTTP_DEBUG.run([], () => {
|
|
317
|
+
route(req, res).catch((error) => send(res, 200, { ok: false, error: String(error?.message || error) }));
|
|
318
|
+
});
|
|
319
|
+
},
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
ctx.effect(() => server.register({ kind: 'prefix', path: '/dsh-contextos-devops/monaco', handler: makeStatic('/dsh-contextos-devops/monaco', monacoRoot) }));
|
|
323
|
+
ctx.effect(() => server.register({ kind: 'prefix', path: '/dsh-contextos-devops/xterm', handler: makeStatic('/dsh-contextos-devops/xterm', xtermRoot) }));
|
|
324
|
+
ctx.effect(() => server.register({ kind: 'prefix', path: '/dsh-contextos-devops/three', handler: makeStatic('/dsh-contextos-devops/three', threeRoot) }));
|
|
325
|
+
ctx.effect(() => server.register({ kind: 'prefix', path: '/dsh-contextos-devops/leaflet', handler: makeStatic('/dsh-contextos-devops/leaflet', leafletRoot) }));
|
|
326
|
+
ctx.effect(() => server.register({ kind: 'prefix', path: '/dsh-contextos-devops/assets', handler: makeStatic('/dsh-contextos-devops/assets', assetsRoot) }));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
|
package/lib/base/env.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// 路径与环境:所有目录推导只在这里,业务代码不拼 HOME。
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export const HOME = () => os.homedir();
|
|
7
|
+
|
|
8
|
+
export function ctxRoot() {
|
|
9
|
+
return process.env.CTX_ROOT || path.join(os.homedir(), 'ContextOS');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function stateDir() {
|
|
13
|
+
if (process.env.CTX_DEVOPS_STATE) return process.env.CTX_DEVOPS_STATE;
|
|
14
|
+
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
15
|
+
const dir = path.join(home, 'dsh-contextos-devops');
|
|
16
|
+
const legacy = path.join(home, 'contextos-devops-dsh');
|
|
17
|
+
// 改名一次性迁移:旧目录里的节点缓存/会话/请求日志不能随插件改名丢掉
|
|
18
|
+
try {
|
|
19
|
+
if (!fs.existsSync(dir) && fs.existsSync(legacy)) fs.renameSync(legacy, dir);
|
|
20
|
+
} catch {}
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// 基础功能:字段 → ContextOS 路径段(key/value/key/value)。
|
|
2
|
+
import { encode } from './http.js';
|
|
3
|
+
|
|
4
|
+
export function fieldSegments(fields) {
|
|
5
|
+
const out = [];
|
|
6
|
+
for (const [key, value] of Object.entries(fields || {})) {
|
|
7
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(key)) throw new Error(`bad field name: ${key}`);
|
|
8
|
+
if (value === undefined || value === null || String(value) === '') continue;
|
|
9
|
+
if (String(value).includes('/')) throw new Error(`field ${key}: ContextOS splits URL paths on "/", use a full-width / instead`);
|
|
10
|
+
out.push(`${encode(key)}/${encode(value)}`);
|
|
11
|
+
}
|
|
12
|
+
return out.join('/');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 修改类请求:只有表单字段进路径;*_uid 结尾的进查询参数
|
|
16
|
+
export function splitFields(fields) {
|
|
17
|
+
const path = {};
|
|
18
|
+
const query = {};
|
|
19
|
+
for (const [key, value] of Object.entries(fields || {})) {
|
|
20
|
+
if (value === undefined || value === null || String(value) === '') continue;
|
|
21
|
+
if (/_uid$/.test(key)) query[key] = String(value);
|
|
22
|
+
else path[key] = value;
|
|
23
|
+
}
|
|
24
|
+
return { path, query };
|
|
25
|
+
}
|
package/lib/base/http.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// 对 ContextOS 节点的 HTTP 访问:统一注入 user.uid、记录调试请求、探活与端口探测。
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
+
|
|
5
|
+
export const USER_CACHE = new Map();
|
|
6
|
+
export const HTTP_DEBUG = new AsyncLocalStorage();
|
|
7
|
+
|
|
8
|
+
export function baseUrl(port) {
|
|
9
|
+
return `http://127.0.0.1:${port}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function encode(v) {
|
|
13
|
+
return encodeURIComponent(String(v));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** ContextOS answers with plain text tables or a JSON status envelope. */
|
|
17
|
+
export function envelopeRows(text) {
|
|
18
|
+
try {
|
|
19
|
+
const env = JSON.parse(text);
|
|
20
|
+
const columns = Array.isArray(env.append) ? env.append : [];
|
|
21
|
+
const height = columns.length && Array.isArray(env[columns[0]]) ? env[columns[0]].length : 0;
|
|
22
|
+
return { columns, rows: Array.from({ length: height }, (_, i) => Object.fromEntries(columns.map((c) => [c, Array.isArray(env[c]) ? env[c][i] : '']))) };
|
|
23
|
+
} catch {
|
|
24
|
+
return { columns: [], rows: [] };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 返回数据只有两种形态:列表(append + 列数组)与详情(option 单条记录)。 */
|
|
29
|
+
export function envelopeShape(text) {
|
|
30
|
+
let env;
|
|
31
|
+
try { env = JSON.parse(text); } catch { return { kind: 'text', raw: String(text || '') }; }
|
|
32
|
+
if (!env || typeof env !== 'object' || Array.isArray(env)) return { kind: 'text', raw: String(text || '') };
|
|
33
|
+
const columns = Array.isArray(env.append) ? env.append : [];
|
|
34
|
+
if (columns.length) {
|
|
35
|
+
const height = Array.isArray(env[columns[0]]) ? env[columns[0]].length : 0;
|
|
36
|
+
const rows = Array.from({ length: height }, (_, i) => Object.fromEntries(columns.map((c) => [c, Array.isArray(env[c]) ? env[c][i] : ''])));
|
|
37
|
+
return { kind: 'list', columns, rows, env };
|
|
38
|
+
}
|
|
39
|
+
if (env.option && typeof env.option === 'object' && !Array.isArray(env.option) && Object.keys(env.option).length) {
|
|
40
|
+
return { kind: 'detail', fields: env.option, env };
|
|
41
|
+
}
|
|
42
|
+
if (Array.isArray(env.detail) && env.detail.length) {
|
|
43
|
+
const fields = {};
|
|
44
|
+
for (const k of env.detail) fields[k] = env.option?.[k] ?? env[k] ?? '';
|
|
45
|
+
return { kind: 'detail', fields, env };
|
|
46
|
+
}
|
|
47
|
+
return { kind: 'empty', env };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function http(port, pathname, { method = 'GET', timeout = 15000, headers = {}, auth = true, redirect = 'follow', token = '', sessid = '', cookie = '' } = {}) {
|
|
51
|
+
// cookie:平台级会话的完整值 space <pod> <command> check <sessid>
|
|
52
|
+
if (cookie) headers = { ...headers, Cookie: `sessid=${cookie}` };
|
|
53
|
+
// 远程 / 容器节点:身份来自 token 或 sessid(由 /c/web.token/create 或 /c/aaa.sess/create 取得)
|
|
54
|
+
if (sessid) pathname += (pathname.includes('?') ? '&' : '?') + 'sessid=' + encode(sessid);
|
|
55
|
+
else if (token) pathname += (pathname.includes('?') ? '&' : '?') + 'token=' + encode(token);
|
|
56
|
+
const cached = USER_CACHE.get(Number(port));
|
|
57
|
+
// 本机访问免登录、全 root,可以任意指定 user.uid;但那只对「应用级」命令有意义
|
|
58
|
+
// (/s/<pod>/c/web.team.<mod>.portal 及其子命令)。系统级命令不认它,别往日志里塞。
|
|
59
|
+
const appLevel = /^\/s\/[^/]+\/c\/web\.team\./.test(pathname);
|
|
60
|
+
if (auth && appLevel && cached?.uid && !/(?:\?|&)(?:user\.uid|sessid)=/.test(pathname)) {
|
|
61
|
+
pathname += (pathname.includes('?') ? '&' : '?') + 'user.uid=' + encode(cached.uid);
|
|
62
|
+
}
|
|
63
|
+
const fullUrl = baseUrl(port) + pathname;
|
|
64
|
+
const requestHeaders = { accept: 'text/plain, application/json', ...headers };
|
|
65
|
+
const started = Date.now();
|
|
66
|
+
const res = await fetch(fullUrl, {
|
|
67
|
+
method,
|
|
68
|
+
signal: AbortSignal.timeout(timeout),
|
|
69
|
+
headers: requestHeaders,
|
|
70
|
+
redirect,
|
|
71
|
+
});
|
|
72
|
+
const text = await res.text();
|
|
73
|
+
const responseHeaders = Object.fromEntries(res.headers.entries());
|
|
74
|
+
const logId = responseHeaders['log-id'] || responseHeaders['x-log-id'] || '';
|
|
75
|
+
const entry = { method, url: fullUrl, requestHeaders, responseHeaders, status: res.status, size: Buffer.byteLength(text), ms: Date.now() - started, logId, body: text.slice(0, 200000) };
|
|
76
|
+
if (!/\/c\/version(?:\?|$)/.test(fullUrl) && !/\/v\/index\.css/.test(fullUrl)) HTTP_DEBUG.getStore()?.push(entry);
|
|
77
|
+
return { status: res.status, size: entry.size, text, headers: responseHeaders, logId, ms: entry.ms, requestHeaders };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 文件上传:系统级命令统一走 <cmd>/action/upload(multipart/form-data)。 */
|
|
81
|
+
export async function upload(port, pathname, { name = 'file', filename = 'upload.bin', data, fields = {}, timeout = 60000 } = {}) {
|
|
82
|
+
const form = new FormData();
|
|
83
|
+
for (const [k, v] of Object.entries(fields || {})) form.append(k, String(v));
|
|
84
|
+
const bytes = typeof data === 'string' ? Buffer.from(data) : data;
|
|
85
|
+
form.append(name, new Blob([bytes]), filename);
|
|
86
|
+
const started = Date.now();
|
|
87
|
+
const res = await fetch(baseUrl(port) + pathname, { method: 'POST', body: form, signal: AbortSignal.timeout(timeout) });
|
|
88
|
+
const text = await res.text();
|
|
89
|
+
const responseHeaders = Object.fromEntries(res.headers.entries());
|
|
90
|
+
const entry = { method: 'POST', url: baseUrl(port) + pathname, requestHeaders: {}, responseHeaders, status: res.status, size: Buffer.byteLength(text), ms: Date.now() - started, logId: responseHeaders['log-id'] || '', body: text.slice(0, 200000) };
|
|
91
|
+
HTTP_DEBUG.getStore()?.push(entry);
|
|
92
|
+
return { status: res.status, size: entry.size, text, headers: responseHeaders, ms: entry.ms };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Iron law 4: status + length + a known field, never status alone. */
|
|
96
|
+
/** 运行时信息:/c/runtime 一次拿到版本、编译时间、程序大小 */
|
|
97
|
+
export function parseRuntime(text) {
|
|
98
|
+
try {
|
|
99
|
+
const d = JSON.parse(text);
|
|
100
|
+
const boot = d.boot || {};
|
|
101
|
+
const make = d.make || {};
|
|
102
|
+
const hash = String(make.hash || '');
|
|
103
|
+
const ver = [make.version, make.forword, hash.slice(0, 6)].filter(Boolean).join('-');
|
|
104
|
+
return {
|
|
105
|
+
ok: !!(boot && make && (boot.bin || boot.pathname)),
|
|
106
|
+
boot, make,
|
|
107
|
+
version: ver, // v3.2.5-2458-d3a68b
|
|
108
|
+
builtAt: String(make.time || boot.time || ''),
|
|
109
|
+
sizeText: String(boot.size || ''),
|
|
110
|
+
hash, node: d.node || {}, host: d.host || {},
|
|
111
|
+
};
|
|
112
|
+
} catch { return { ok: false }; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function probe(port, timeout = 3000) {
|
|
116
|
+
try {
|
|
117
|
+
const v = await http(port, '/c/runtime', { timeout });
|
|
118
|
+
const rt = parseRuntime(v.text);
|
|
119
|
+
const css = await http(port, '/v/index.css', { timeout });
|
|
120
|
+
return {
|
|
121
|
+
live: v.status === 200 && rt.ok && css.size > 10000,
|
|
122
|
+
port,
|
|
123
|
+
runtime: rt,
|
|
124
|
+
version: rt.version || '',
|
|
125
|
+
builtAt: rt.builtAt || '',
|
|
126
|
+
cssBytes: css.size,
|
|
127
|
+
};
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return { live: false, port, error: String(error?.message || error) };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function portFree(port) {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
const s = net.createServer();
|
|
136
|
+
s.once('error', () => resolve(false));
|
|
137
|
+
s.once('listening', () => s.close(() => resolve(true)));
|
|
138
|
+
s.listen(port, '127.0.0.1');
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// 基础功能逻辑:OS 差异(可 import misc)。base 不 import core。
|
|
2
|
+
import { isWin, spawnDetached } from '../misc/shell.js';
|
|
3
|
+
|
|
4
|
+
export const isMac = process.platform === 'darwin';
|
|
5
|
+
export { isWin };
|
|
6
|
+
export const isLinux = process.platform === 'linux';
|
|
7
|
+
export const osName = isMac ? 'darwin' : isWin ? 'windows' : 'linux';
|
|
8
|
+
export const archName = process.arch === 'arm64' ? 'arm64' : process.arch === 'ia32' ? '386' : 'amd64';
|
|
9
|
+
export const platformTriple = () => `${osName}.${archName}`;
|
|
10
|
+
|
|
11
|
+
/** sqlite 可执行文件:Windows 走 PATH,macOS/Linux 用常见绝对路径并回退 PATH。 */
|
|
12
|
+
export function sqliteBin() {
|
|
13
|
+
if (isWin) return 'sqlite3.exe';
|
|
14
|
+
if (isMac) return '/usr/bin/sqlite3';
|
|
15
|
+
return 'sqlite3';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 用系统浏览器打开 URL。 */
|
|
19
|
+
export function openBrowser(url) {
|
|
20
|
+
if (isMac) return spawnDetached('open', [url]);
|
|
21
|
+
if (isWin) return spawnDetached('cmd.exe', ['/c', 'start', '', url]);
|
|
22
|
+
return spawnDetached('xdg-open', [url]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 在指定目录打开系统终端。 */
|
|
26
|
+
export function openTerminalAt(dir) {
|
|
27
|
+
if (isMac) return spawnDetached('open', ['-a', 'Terminal', dir]);
|
|
28
|
+
if (isWin) return spawnDetached('cmd.exe', ['/c', 'start', 'cmd', '/k', `cd /d "${dir}"`]);
|
|
29
|
+
return spawnDetached('x-terminal-emulator', ['-e', 'bash', '-c', `cd "${dir}" && exec bash`]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 内嵌 PTY 是否可用(Windows 无 pty.fork,需要 ConPTY 后端)。 */
|
|
33
|
+
export const ptySupported = !isWin;
|