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,675 @@
1
+ // 核心业务:应用(模板生成/代码/实例/商店/最近应用)。
2
+ import fs from 'node:fs/promises';
3
+ import fsp from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ import { http, encode, baseUrl, envelopeRows } from '../base/http.js';
7
+ import { listNodes } from '../base/registry.js';
8
+ import { ctxRoot } from '../base/env.js';
9
+ import { DatabaseSync } from 'node:sqlite';
10
+ import { fieldSegments } from '../base/field.js';
11
+ import { waitFor } from '../misc/async.js';
12
+ import { initSpace, buildSpace, ensureSpaceProcess, startSpace } from './spaces.js';
13
+ import { goBinary } from './gobuild.js';
14
+ import { resolveActorUid } from './identity.js';
15
+ import { ensureUser } from './users.js';
16
+
17
+ export async function storeApps({ node, lang = 'zh' } = {}) {
18
+ const uid = await ensureUser(node);
19
+ const r = await http(node, `/s/20240724-community/c/web.team.gonganxitong.service?user.uid=${encode(uid)}`, { timeout: 15000 });
20
+ if (r.status !== 200) throw new Error(`service store failed: HTTP ${r.status}`);
21
+ const { rows } = envelopeRows(r.text);
22
+ return rows.map((row) => {
23
+ const status = row.service_status || 'offline';
24
+ const actions = ['create', 'code', 'conf', 'data', ...(status === 'online' ? ['offline'] : ['online'])];
25
+ return {
26
+ uid: row.uid || '',
27
+ name: row.name || '',
28
+ title: row.name || '',
29
+ space: row.space || '',
30
+ command: row.index || '',
31
+ place: row.place_key || '',
32
+ module: row.module || '',
33
+ version: row.version || '',
34
+ icon: row.icon || '',
35
+ status,
36
+ status_label: status === 'online' ? (lang === 'zh' ? '已上架' : 'online') : (lang === 'zh' ? '待上架' : 'offline'),
37
+ actions,
38
+ };
39
+ }).filter((x) => x.name && x.command);
40
+ }
41
+
42
+ export async function storeAction({ node, space = '20240724-community', serviceUid = '', action = '' } = {}) {
43
+ if (!/^(online|offline|code|conf|data)$/.test(action)) throw new Error('unsupported store action');
44
+ const uid = await ensureUser(node);
45
+ const r = await http(node, `/s/${encode(space)}/c/web.team.gonganxitong.service/action/${action}?uid=${encode(serviceUid)}&user.uid=${encode(uid)}`, { method: 'POST', timeout: 20000 });
46
+ if (r.status !== 200) throw new Error(`store ${action} failed: HTTP ${r.status}`);
47
+ return { action, serviceUid, status: r.status };
48
+ }
49
+
50
+ export async function createStoreInstance({ node, service = {}, name = '', city = '深圳市', company = '云轩领航', uid = '' } = {}) {
51
+ if (!service.space || !service.command) throw new Error('service space/command is required');
52
+ const title = name || String(service.name || '').split(/\s+/)[0] || '我的应用';
53
+ const r = await createInstance({ node, space: service.space, command: service.command, place: service.place || '', name: title, city, company, serviceUid: service.uid, uid });
54
+ return { ...r, service };
55
+ }
56
+
57
+ export async function listApps({ node, space, avatarUid = '' } = {}) {
58
+ const n = (await listNodes()).find((x) => x.port === Number(node));
59
+ if (!n) throw new Error(`node ${node} not found`);
60
+ // One identity per node: an explicit avatar wins, otherwise the node's own
61
+ // account — never "no identity", so create/list/visit always agree.
62
+ const uid = await resolveActorUid(node, avatarUid);
63
+ const r = await http(node, `/s/20240724-community/c/web.team.gonganxitong.portal/action/run/web.team.gonganxitong.recent?user.uid=${encode(uid)}`, { timeout: 12000 });
64
+ if (r.status !== 200) {
65
+ // A node without the community space still gets a filesystem view.
66
+ return scanApps({ node, space });
67
+ }
68
+ const { rows } = envelopeRows(r.text);
69
+ const seen = new Set();
70
+ const apps = [];
71
+ for (const row of rows) {
72
+ const command = String(row.index || '');
73
+ if (!command) continue;
74
+ const name = command.split('.')[2] || '';
75
+ const key = `${row.space}|${command}|${row.place_uid}`;
76
+ if (seen.has(key)) continue;
77
+ seen.add(key);
78
+ let place = '';
79
+ let functions = [];
80
+ if (name && row.space) {
81
+ try {
82
+ const portal = JSON.parse(await fs.readFile(path.join(n.dir, 'usr', 'local', 'work', row.space, 'src', name, 'portal.json'), 'utf8'));
83
+ place = Object.keys(portal.input || {}).find((k) => /_name$/.test(k)) || '';
84
+ functions = Object.keys(portal).filter((k) => !['portal', 'input', 'value'].includes(k));
85
+ } catch { /* app may not be on this disk */ }
86
+ }
87
+ apps.push({
88
+ name,
89
+ title: row.place_name || row.service_name || name,
90
+ service: row.service_name || '',
91
+ service_name: row.service_name || '',
92
+ city_name: row.city_name || '',
93
+ company_name: row.company_name || row.street_name || '',
94
+ command,
95
+ space: row.space || space || '',
96
+ place_uid: row.place_uid || '',
97
+ user_uid: uid,
98
+ updated: row.updated_at || '',
99
+ icon: row.place_avatar || row.service_icon || '',
100
+ place_avatar: row.place_avatar || '',
101
+ path: name ? `src/${name}/portal.json` : '',
102
+ place,
103
+ functions,
104
+ recent: true,
105
+ });
106
+ }
107
+ return apps;
108
+ }
109
+
110
+ export async function scanApps({ node, space } = {}) {
111
+ const n = (await listNodes()).find((x) => x.port === Number(node));
112
+ if (!n) throw new Error(`node ${node} not found`);
113
+ if (!/^[A-Za-z0-9_-]+$/.test(space || '')) throw new Error('bad space name');
114
+ const src = path.join(n.dir, 'usr', 'local', 'work', space, 'src');
115
+ let entries = [];
116
+ try { entries = await fs.readdir(src, { withFileTypes: true }); } catch { return []; }
117
+ const apps = [];
118
+ for (const entry of entries) {
119
+ if (!entry.isDirectory()) continue;
120
+ const portalPath = path.join(src, entry.name, 'portal.json');
121
+ let portal;
122
+ try { portal = JSON.parse(await fs.readFile(portalPath, 'utf8')); } catch { continue; }
123
+ const stat = await fs.stat(portalPath);
124
+ apps.push({
125
+ name: entry.name,
126
+ title: portal.portal || entry.name,
127
+ command: `web.team.${entry.name}.portal`,
128
+ path: `src/${entry.name}/portal.json`,
129
+ port: Number(node),
130
+ space,
131
+ updated: stat.mtime.toISOString(),
132
+ functions: Object.keys(portal).filter((k) => !['portal', 'input', 'value'].includes(k)),
133
+ place: Object.keys(portal.input || {}).find((k) => /_name$/.test(k)) || '',
134
+ });
135
+ }
136
+ return apps.sort((a, b) => String(b.updated).localeCompare(String(a.updated)));
137
+ }
138
+
139
+ // The community autogen action writes model/portal/common but (on the current
140
+ // node build) omits the per-value files that portal.go instantiates. Generate
141
+ // them locally before compiling so a generated app is not a half-written tree.
142
+ function ensureAutogenValues(dir, name, place, values) {
143
+ const appDir = path.join(dir, 'src', name);
144
+ fsp.mkdirSync(appDir, { recursive: true });
145
+ values.forEach((value, index) => {
146
+ const file = path.join(appDir, `${value}.go`);
147
+ if (fsp.existsSync(file)) return;
148
+ const source = `package ${name}
149
+
150
+ import "shylinux.com/x/ice"
151
+
152
+ type ${value} struct {
153
+ \tTable
154
+ \torder string \`data:"${index + 1}"\`
155
+ \tfields string \`data:"title,content,user_uid"\`
156
+ \tcreate string \`name:"create title* content" role:"worker"\`
157
+ \tlist string \`name:"list ${place}_uid uid auto" role:"worker"\`
158
+ }
159
+
160
+ func (s ${value}) Remove(m *ice.Message, arg ...string) {
161
+ \ts.ValueRemove(m, arg...)
162
+ }
163
+ func (s ${value}) List(m *ice.Message, arg ...string) {
164
+ \ts.ValueList(m, arg)
165
+ \ts.TableButton(m, func(value ice.Maps) (button []ice.Any) {
166
+ \t\treturn append(button, s.Modify, s.Remove)
167
+ \t})
168
+ }
169
+
170
+ func init() { ice.TeamCtxCmd(${value}{}) }
171
+ `;
172
+ fsp.writeFileSync(file, source);
173
+ });
174
+ }
175
+
176
+
177
+ // 生成应用模板:调云社区服务 service/action/autogen(name/help/place/value/trans/space)。
178
+ const APP_CHAIN = 'target,plan,task,report,feedback';
179
+ const APP_CHAIN_CN = '目标,计划,任务,报告,反馈';
180
+
181
+ // service autogen 的参数表直接读命令自省接口里的 meta.autogen,
182
+ // 不在客户端另写一份参数清单。
183
+ // 应用模板名不能和云社区 service 列表、目标空间已有模块重名。
184
+ // 返回一个可用名字(重名时自动加序号),客户端把它填进表单。
185
+ export async function uniqueAppName({ node, space = '', name = '' } = {}) {
186
+ if (!node) throw new Error('node is required');
187
+ const base = String(name || '').trim();
188
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(base)) throw new Error('app name must match [a-zA-Z][a-zA-Z0-9_]*');
189
+ const used = new Set();
190
+ try {
191
+ for (const s of await storeApps({ node })) {
192
+ const m = /^web\.team\.([a-zA-Z0-9_]+)\./i.exec(String(s.command || ''));
193
+ if (m) used.add(m[1].toLowerCase());
194
+ }
195
+ } catch { /* service 列表拿不到时只按本地模块判断 */ }
196
+ const n = (await listNodes()).find((x) => x.port === Number(node));
197
+ if (n && space) {
198
+ const dir = path.join(n.dir, 'usr', 'local', 'work', space, 'src');
199
+ try {
200
+ for (const e of await fs.readdir(dir, { withFileTypes: true })) {
201
+ if (e.isDirectory()) used.add(e.name.toLowerCase());
202
+ }
203
+ } catch { /* 空间还没克隆源码 */ }
204
+ }
205
+ let out = base;
206
+ let i = 2;
207
+ while (used.has(out.toLowerCase())) out = `${base}${i++}`;
208
+ return { name: out, base, renamed: out !== base, used: [...used].sort() };
209
+ }
210
+
211
+ export async function autogenSpec({ node } = {}) {
212
+ if (!node) throw new Error('node is required');
213
+ const r = await http(node, '/s/20240724-community/c/command/web.team.gonganxitong.service', { method: 'GET', timeout: 12000 });
214
+ if (r.status !== 200) throw new Error(`autogen spec failed: HTTP ${r.status}`);
215
+ const env = JSON.parse(r.text);
216
+ const meta = JSON.parse((Array.isArray(env.meta) ? env.meta[0] : env.meta) || '{}');
217
+ const list = Array.isArray(meta.autogen) ? meta.autogen : [];
218
+ if (!list.length) throw new Error('service autogen meta is empty');
219
+ const trans = (meta._trans && meta._trans.input) || {};
220
+ const fields = list.map((f) => ({
221
+ name: String(f.name || ''),
222
+ label: trans[f.name] || String(f.name || ''),
223
+ required: f.need === 'must',
224
+ type: f.type || 'text',
225
+ })).filter((f) => f.name);
226
+ return { fields, source: 'service.meta.autogen', raw: list };
227
+ }
228
+
229
+ export async function autogenService({ node, space, name, help = '', place = '', value = APP_CHAIN, trans = APP_CHAIN_CN } = {}) {
230
+ if (!node || !space) throw new Error('node and space are required');
231
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name || '')) throw new Error('app name must match [a-zA-Z][a-zA-Z0-9_]*');
232
+ const uid = await ensureUser(node);
233
+ const { valueText, transText } = normalizeValueTrans(value || APP_CHAIN, trans || APP_CHAIN_CN);
234
+ const seg = fieldSegments({ name, help: help || name, place, value: valueText, trans: transText, space });
235
+ const r = await http(node, `/s/20240724-community/c/web.team.gonganxitong.service/action/autogen/${seg}?user.uid=${encode(uid)}`, { method: 'POST', timeout: 180000 });
236
+ if (r.status !== 200) throw new Error(`autogen failed: HTTP ${r.status}`);
237
+ const n = (await listNodes()).find((x) => x.port === Number(node));
238
+ const dir = n ? path.join(n.dir, 'usr', 'local', 'work', space, 'src', name) : '';
239
+ const made = dir ? fsp.existsSync(dir) : false;
240
+ // 生成后必须编译空间,模板才能在应用商店里被创建(未编译时实例会 404)。
241
+ let built = false;
242
+ let buildNote = '';
243
+ let warm = false;
244
+ if (made) {
245
+ try {
246
+ const b = await buildSpace({ node, space });
247
+ built = !!(b && b.rebuilt);
248
+ // 编译会重启空间;重启后有一段时间所有动作只回 echo 回执。
249
+ // 不把这段时间等掉,用户第一次点「创建实例」必然失败。
250
+ // 只探一次,不等待:界面用 spaceReady 轮询显示「编译中/已就绪」
251
+ warm = await isCommandReady({ node, space, command: `web.team.${name}.portal` });
252
+ } catch (e) {
253
+ buildNote = e.message || String(e);
254
+ }
255
+ }
256
+ return { space, name, dir, created: made, built, warm, buildNote, via: 'service' };
257
+ }
258
+
259
+ // value/trans 成对归一:value 用英文逗号,trans 用中文顿号,
260
+ // 数量不一致时按 value 补齐(缺失的中文名退回英文名,绝不丢项)。
261
+ export function normalizeValueTrans(value, trans) {
262
+ const names = String(value || '').split(/[,,、;;\s]+/).filter(Boolean);
263
+ const labels = String(trans || '').split(/[,,、;;]+/).map((x) => x.trim()).filter(Boolean);
264
+ const fixed = names.map((n, i) => (labels[i] || n).replace(/[,,、;;]/g, ''));
265
+ return { valueText: names.join(','), transText: fixed.join('、') };
266
+ }
267
+
268
+ export async function autogenApp({ node, space, name, help = '', place = 'profile', value = 'profile', trans = '' } = {}) {
269
+ if (!goBinary()) throw new Error('Go toolchain not found; install usr/local/go or set GO');
270
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name || '')) throw new Error('app name must match [a-zA-Z][a-zA-Z0-9_]*');
271
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(place || '')) throw new Error('place must match [a-zA-Z][a-zA-Z0-9_]*');
272
+ const values = String(value || '').split(/[,,、]/).map((x) => x.trim()).filter(Boolean);
273
+ if (!values.length || values.some((x) => !/^[a-zA-Z][a-zA-Z0-9_]*$/.test(x))) {
274
+ throw new Error('value must be comma-separated ASCII field names; put Chinese labels in trans');
275
+ }
276
+ const n = (await listNodes()).find((x) => x.port === Number(node));
277
+ if (!n) throw new Error(`node ${node} not found`);
278
+ const dir = path.join(n.dir, 'usr', 'local', 'work', space);
279
+ if (!fsp.existsSync(path.join(dir, '.git'))) await initSpace({ node, space });
280
+ const title = help || name;
281
+ // service/autogen 按中文顿号「、」切分 trans;统一把所有分隔符归一为「、」,
282
+ // 保证每个 value 都有对应的中文名(少一个就会渲染成空标题)。
283
+ const { valueText, transText } = normalizeValueTrans(value, trans);
284
+ const q = new URLSearchParams({ name, help: title, place, value: valueText, trans: transText, space });
285
+ const r = await http(node, `/s/20240724-community/c/web.team.gonganxitong.service/action/autogen?${q}`, {
286
+ method: 'POST',
287
+ timeout: 60000,
288
+ });
289
+ if (r.status !== 200) throw new Error(`autogen failed: HTTP ${r.status}`);
290
+ const portalFile = path.join(dir, 'src', name, 'portal.json');
291
+ await waitFor(async () => fsp.existsSync(portalFile), { timeout: 20000, interval: 500, label: `app ${name}/portal.json` });
292
+ const work = await ensureGoWork(dir).catch((e) => ({ used: false, error: String(e?.message || e) }));
293
+ // The autogen template imports ice/devops, community, enterprise and
294
+ // mysql-story. Without the root ice requirement Go tries to fetch each
295
+ // subpackage as its own module and fails with "declares its path as ice".
296
+ const module = await modulePath(space, n.dir);
297
+ await ensureGoMod({ node, space, dir: n.dir, module });
298
+ ensureAutogenValues(dir, name, place, values);
299
+ await buildSpace({ node, space });
300
+ return {
301
+ node: Number(node),
302
+ space,
303
+ name,
304
+ help: title,
305
+ place,
306
+ command: `web.team.${name}.portal`,
307
+ path: `src/${name}/portal.json`,
308
+ built: true,
309
+ warning: '',
310
+ goWork: work,
311
+ };
312
+ }
313
+
314
+ // 空间重启期间所有动作只回 echo 回执(body 只有 detail),此时创建实例必然失败。
315
+ // 这里只做「一次性问一下」,供界面轮询,不阻塞任何请求。
316
+ export async function isCommandReady({ node, space, command } = {}) {
317
+ try {
318
+ const r = await http(node, `/s/${encode(space)}/c/${command}`, { timeout: 6000 });
319
+ const txt = String(r.text || '');
320
+ return r.status === 200 && /"option"\s*:/.test(txt) && !/"detail"\s*:/.test(txt);
321
+ } catch {
322
+ return false;
323
+ }
324
+ }
325
+
326
+ export async function createInstance({ node, space, command, place = '', name = '我的应用', city = '深圳市', company = '云轩领航', serviceUid = '', uid = '' } = {}) {
327
+ if (!space || !command) throw new Error('space and command are required');
328
+ const actorUid = await resolveActorUid(node, uid);
329
+ // 固定三个参数,按声明顺序:city_name / company_name / system_name(应用名)
330
+ const placeKey = /_name$/.test(place) ? place : `${place || 'system'}_name`;
331
+ const fields = { city_name: city, company_name: company, [placeKey]: name };
332
+ const seg = fieldSegments(fields);
333
+ const extra = serviceUid ? `&service_uid=${encode(serviceUid)}` : '';
334
+ const placeUrl = `/s/${encode(space)}/c/${command}/action/placeCreate/${seg}?user.uid=${encode(actorUid)}${extra}`;
335
+ let r = await http(node, placeUrl, { method: 'PUT', timeout: 30000, redirect: 'manual', headers: { accept: 'application/json' } });
336
+ let body = String(r.text || '');
337
+ let status = Number(r.status);
338
+ if (status !== 200 && status !== 201) throw new Error(`placeCreate failed: HTTP ${status} ${body.slice(0, 120)}`);
339
+ // 空回执通常是空间正在重启(echo)。只再试一次,然后明确报错,绝不长等。
340
+ if (!/place_uid|_process|project_uid|system_uid|warn:/.test(body)) {
341
+ await new Promise((ok) => setTimeout(ok, 1500));
342
+ r = await http(node, placeUrl, { method: 'PUT', timeout: 30000, redirect: 'manual', headers: { accept: 'application/json' } });
343
+ body = String(r.text || '');
344
+ status = Number(r.status);
345
+ if (status !== 200 && status !== 201) throw new Error(`placeCreate failed: HTTP ${status} ${body.slice(0, 120)}`);
346
+ if (!/place_uid|_process|project_uid|system_uid|warn:/.test(body)) {
347
+ throw new Error('空间正在编译/重启(命令尚未注册),请等提示「模板已就绪」后再创建实例');
348
+ }
349
+ }
350
+ let env = {};
351
+ try { env = JSON.parse(body); } catch { /* plain text reply */ }
352
+ // `_process: ["_open"]` means "open this URL next": `_arg[0]` is the new
353
+ // instance URL and its query already carries the place uid (`project_uid`,
354
+ // `system_uid`, …). Opening it is also what registers 最近应用.
355
+ const process = env._process?.[0] || env._process || '';
356
+ const openUrl = String((Array.isArray(env._arg) ? env._arg[0] : env._arg) || '');
357
+ let opened = '';
358
+ let placeUid = '';
359
+ let visited = false;
360
+ let tried = '';
361
+ if (process === '_open' && openUrl) {
362
+ let pathname = openUrl;
363
+ try { const u = new URL(openUrl); pathname = u.pathname + u.search; } catch { /* already a path */ }
364
+ const q = pathname.includes('?') ? pathname.slice(pathname.indexOf('?') + 1) : '';
365
+ const params = new URLSearchParams(q);
366
+ placeUid = params.get('project_uid') || params.get('system_uid') || params.get('place_uid') || params.get('target_uid') || params.get('app_uid') || '';
367
+ if (!placeUid) placeUid = (pathname.match(/\/c\/[^/?]+\/([0-9a-f]{32})/) || [])[1] || '';
368
+ const visit = pathname.replace(/([?&])debug=true&?/, '$1').replace(/[?&]$/, '');
369
+ const withUser = /(?:\?|&)user\.uid=/.test(visit) ? visit : `${visit}${visit.includes('?') ? '&' : '?'}user.uid=${encode(actorUid)}&user_uid=${encode(actorUid)}`;
370
+ // 最近应用 is written by the portal's `arg[0]` (place uid in the path); the
371
+ // `_open` URL carries it as a query option, so visit the path form as well.
372
+ const pathVisit = `/s/${encode(space)}/c/${command}/${encode(placeUid)}?user.uid=${encode(actorUid)}&user_uid=${encode(actorUid)}${extra}`;
373
+ tried = pathVisit;
374
+ try {
375
+ const page = await http(node, pathVisit, { timeout: 20000 });
376
+ visited = page.status === 200;
377
+ } catch { /* best effort */ }
378
+ opened = withUser;
379
+ try { await http(node, withUser, { timeout: 20000 }); } catch { /* open as instructed */ }
380
+ }
381
+ // Older replies only carry `warn: not allow: <user_place_uid>` or a redirect.
382
+ const warnUid = (/^warn:\s*not allow:\s*([0-9a-f]{32})\s*$/i.exec(body.trim()) || [])[1] || '';
383
+ if (!placeUid && !warnUid && /^warn:/i.test(body.trim())) throw new Error(`placeCreate rejected: ${body.trim().slice(0, 200)}`);
384
+ let userPlaceUid = warnUid || (env.uid?.[0] || '');
385
+ if (!placeUid && !userPlaceUid) {
386
+ const location = String(r.headers?.location || '');
387
+ userPlaceUid = (location.match(/\/c\/[^/?]+\/([0-9a-f]{32})/) || [])[1] || '';
388
+ }
389
+ if (!placeUid) {
390
+ // Derive the place uid from the reply uid / portal list / space sqlite, then
391
+ // visit until the portal resolves the place (it answers with `_place_name`).
392
+ let rows = [];
393
+ try {
394
+ const page = await http(node, `/s/${encode(space)}/c/${command}?user.uid=${encode(actorUid)}${extra}`, { timeout: 12000 });
395
+ rows = envelopeRows(page.text).rows;
396
+ } catch { /* list may be inaccessible */ }
397
+ const row = rows.find((x) => x.uid === userPlaceUid)
398
+ || rows.filter((x) => !serviceUid || String(x.service_uid || '') === String(serviceUid)).reverse()
399
+ .find((x) => String(x[placeKey] || x.name || x.title || '') === name);
400
+ const skip = /^(uid|user_uid|company_uid|auth_uid|city_uid|street_uid|service_uid|avatar_uid|place_uid)$/;
401
+ const queue = [];
402
+ const push = (key, value) => { if (value && /^[0-9a-f]{32}$/.test(value) && !queue.some((c) => c.uid === value)) queue.push({ key, uid: value }); };
403
+ if (row) for (const [k, v] of Object.entries(row)) if (/_uid$/.test(k) && !skip.test(k) && typeof v === 'string') push(k, v);
404
+ push('reply', userPlaceUid);
405
+ if (!queue.length) push('db', await findPlaceUidByDb({ node, space, command, name }));
406
+ for (const c of queue.slice(0, 3)) {
407
+ const path = `/s/${encode(space)}/c/${command}/${encode(c.uid)}?user.uid=${encode(actorUid)}&user_uid=${encode(actorUid)}${extra}`;
408
+ try {
409
+ const page = await http(node, path, { timeout: 20000 });
410
+ let resolved = false;
411
+ try { resolved = Boolean(JSON.parse(page.text)._place_name?.[0]); } catch { /* html portal */ }
412
+ tried = path;
413
+ if (resolved) { placeUid = c.uid; visited = true; break; }
414
+ } catch { tried = path; }
415
+ }
416
+ }
417
+ try {
418
+ const recent = await listApps({ node, avatarUid: actorUid });
419
+ if (placeUid) visited = recent.some((a) => a.place_uid === placeUid) || visited;
420
+ } catch { /* recent is best effort */ }
421
+ return { node: Number(node), space, command, place: placeKey, name, city, company, uid: actorUid, place_uid: placeUid, result: placeUid, user_place_uid: userPlaceUid, service_uid: serviceUid, created: true, visited, opened, tried, process, hint: placeUid ? '' : body.slice(0, 200) };
422
+ }
423
+
424
+ // The portal list is the normal way to learn a place uid, but some portals
425
+ // (审批系统: `systems`, role void) keep the new place out of the caller's list.
426
+ // Fall back to the space's own sqlite file: the place table is the plural of the
427
+ // `<x>_name` field used by placeCreate.
428
+ export async function findPlaceUidByDb({ node, space, command, name = '' }) {
429
+ const n = (await listNodes()).find((x) => x.port === Number(node));
430
+ if (!n || !space) return '';
431
+ const module = String(command || '').split('.')[2] || '';
432
+ if (!/^[A-Za-z0-9_]+$/.test(module)) return '';
433
+ const dir = path.join(n.dir, 'usr', 'local', 'work', space, 'var', 'db', `${module}.db`);
434
+ let db;
435
+ try { db = new DatabaseSync(dir, { readOnly: true }); } catch { return ''; }
436
+ try {
437
+ const tables = db.prepare("select name from sqlite_master where type='table'").all().map((r) => r.name);
438
+ const wanted = [module, module.replace(/xitong$/, ''), 'places', 'systems', 'projects', 'apps'];
439
+ const picks = tables.filter((t) => wanted.includes(t) || /^(systems|projects|places|apps)$/.test(t));
440
+ for (const t of picks) {
441
+ try {
442
+ const cols = db.prepare(`pragma table_info(${t})`).all().map((c) => c.name);
443
+ if (!cols.includes('uid') || !cols.includes('name')) continue;
444
+ const q = name ? `select uid from ${t} where name = ? order by id desc limit 1` : `select uid from ${t} order by id desc limit 1`;
445
+ const row = name ? db.prepare(q).get(name) : db.prepare(q).get();
446
+ if (row?.uid) return String(row.uid);
447
+ } catch { /* table shape differs */ }
448
+ }
449
+ return '';
450
+ } finally { try { db.close(); } catch { /* ignore */ } }
451
+ }
452
+
453
+ function moduleSource({ zone, name }) {
454
+ const type = name.replace(/[^a-zA-Z0-9_]/g, '');
455
+ return `package ${zone}
456
+
457
+ import "shylinux.com/x/ice"
458
+
459
+ type ${type} struct {
460
+ \tice.Hash
461
+ \tshort string \`data:"index,title"\`
462
+ \tfield string \`data:"time,index,title,content,user_uid"\`
463
+ \tcreate string \`name:"create title* content* user_uid"\`
464
+ \tlist string \`name:"list index auto"\`
465
+ }
466
+
467
+ func init() { ice.Cmd("web.chat.${zone}.${type}", ${type}{}) }
468
+ `;
469
+ }
470
+
471
+ async function modulePath(space, dir) {
472
+ try {
473
+ const goMod = await fs.readFile(path.join(dir, 'usr', 'local', 'work', space, 'go.mod'), 'utf8');
474
+ return /^module\s+(\S+)/m.exec(goMod)?.[1] || space.replace(/^\d{8}-/, '');
475
+ } catch {
476
+ return space.replace(/^\d{8}-/, '');
477
+ }
478
+ }
479
+
480
+ async function ensureGoWork(dir) {
481
+ const work = path.join(ctxRoot(), 'go.work');
482
+ if (!fsp.existsSync(work)) return { used: false, reason: 'no go.work' };
483
+ const code = await new Promise((resolve) => {
484
+ const child = spawn('go', ['work', 'use', dir], { cwd: ctxRoot(), stdio: ['ignore', 'pipe', 'pipe'] });
485
+ let err = '';
486
+ child.stderr.on('data', (d) => { err += String(d); });
487
+ child.on('close', (c) => resolve({ code: c, err }));
488
+ child.on('error', (e) => resolve({ code: -1, err: String(e) }));
489
+ });
490
+ return { used: code.code === 0, code: code.code, error: code.err.trim() };
491
+ }
492
+
493
+
494
+ async function ensureMainImport({ node, space, dir, module, zone }) {
495
+ const mainPath = path.join(dir, 'usr', 'local', 'work', space, 'src', 'main.go');
496
+ let current = '';
497
+ try {
498
+ current = await fs.readFile(mainPath, 'utf8');
499
+ } catch {
500
+ /* template missing; write the canonical bootstrap */
501
+ }
502
+ const imports = new Set([`${module}/src/${zone}`]);
503
+ for (const match of current.matchAll(/^\s*_\s+"([^"]+)"/gm)) imports.add(match[1]);
504
+ const body = `package main\n\nimport (\n${[...imports].map((i) => `\t_ "${i}"`).join('\n')}\n\n\t"shylinux.com/x/ice"\n)\n\nfunc main() { print(ice.Run()) }\n`;
505
+ if (body === current) return;
506
+ const pod = encode(space);
507
+ const saved = await http(
508
+ node,
509
+ `/s/${pod}/c/web.code.vimer/action/save?path=${encode('src/')}&file=main.go&content=${encode(body)}&space.noecho=true`,
510
+ { method: 'POST', timeout: 30000 },
511
+ );
512
+ if (saved.status !== 200) throw new Error(`main.go save failed: HTTP ${saved.status}`);
513
+ }
514
+
515
+ async function ensureGoMod({ node, space, dir, module }) {
516
+ const goModPath = path.join(dir, 'usr', 'local', 'work', space, 'go.mod');
517
+ let current = '';
518
+ try {
519
+ current = await fs.readFile(goModPath, 'utf8');
520
+ } catch {
521
+ /* create it */
522
+ }
523
+ if (/shylinux\.com\/x\/ice\s+v/.test(current)) return;
524
+ const body = `module ${module}\n\ngo 1.13\n\nrequire shylinux.com/x/ice v1.6.6\n`;
525
+ const saved = await http(
526
+ node,
527
+ `/s/${encode(space)}/c/web.code.vimer/action/save?path=&file=go.mod&content=${encode(body)}&space.noecho=true`,
528
+ { method: 'POST', timeout: 30000 },
529
+ );
530
+ if (saved.status !== 200) throw new Error(`go.mod save failed: HTTP ${saved.status}`);
531
+ }
532
+
533
+ export async function createApp({ node, space, name, zone = 'devops', help = '' }) {
534
+ if (!goBinary()) throw new Error('Go toolchain not found; install usr/local/go or set GO');
535
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name || '')) throw new Error('app name must match [a-zA-Z][a-zA-Z0-9_]*');
536
+ if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(zone)) throw new Error('zone must match [a-zA-Z][a-zA-Z0-9_]*');
537
+ const pod = encode(space);
538
+ const short = space.replace(/^\d{8}-/, '');
539
+ const init = await http(
540
+ node,
541
+ `/s/${pod}/c/web.code.git.status/action/init?remote=${encode(`https://shylinux.com/x/${short}`)}&space.noecho=true`,
542
+ { method: 'POST', timeout: 30000 },
543
+ );
544
+ if (init.status !== 200) throw new Error(`git init failed: HTTP ${init.status}`);
545
+
546
+ const mod = await http(
547
+ node,
548
+ `/s/${pod}/c/web.code.vimer/action/module?name=${encode(name)}&zone=${encode(zone)}&type=Hash&help=${encode(help || name)}&space.noecho=true`,
549
+ { method: 'POST', timeout: 30000 },
550
+ );
551
+ if (mod.status !== 200) throw new Error(`module scaffold failed: HTTP ${mod.status}`);
552
+
553
+ const save = await http(
554
+ node,
555
+ `/s/${pod}/c/web.code.vimer/action/save?path=${encode(`src/${zone}/`)}&file=${encode(`${name}.go`)}&content=${encode(moduleSource({ zone, name }))}&space.noecho=true`,
556
+ { method: 'POST', timeout: 30000 },
557
+ );
558
+ if (save.status !== 200) throw new Error(`save failed: HTTP ${save.status}`);
559
+
560
+ const nodeDir = (await listNodes()).find((n) => n.port === Number(node))?.dir || path.join(path.dirname(ctxRoot()), `${path.basename(ctxRoot())}-${node}`);
561
+ const module = await modulePath(space, nodeDir);
562
+ await ensureGoMod({ node, space, dir: nodeDir, module });
563
+ await ensureMainImport({ node, space, dir: nodeDir, module, zone });
564
+
565
+ // Build in-process with the local Go toolchain and restart the space so the
566
+ // daemon loads the new binary; the host PATH may not contain go.
567
+ await buildSpace({ node, space });
568
+
569
+ const command = `web.chat.${zone}.${name}`;
570
+ const reachable = async () => {
571
+ const r = await http(node, `/s/${pod}/c/${command}`, { timeout: 5000 });
572
+ // A restarting space answers 200 with a `space.dial` envelope; that is not
573
+ // the app answering yet.
574
+ return r.status === 200 && r.text.includes(command) && !r.text.includes('space.dial') && !r.text.includes('from.space');
575
+ };
576
+ try {
577
+ await waitFor(reachable, { timeout: 40000, interval: 1000, label: `app ${command}` });
578
+ } catch {
579
+ // compile exits the process for the daemon to pull back; if that did not
580
+ // happen, start the space explicitly and wait once more.
581
+ await ensureSpaceProcess({ node, space, dir: nodeDir });
582
+ await waitFor(reachable, { timeout: 25000, interval: 1000, label: `app ${command}` });
583
+ }
584
+ await new Promise((r) => setTimeout(r, 1200));
585
+ return { node, space, name, zone, command, path: `src/${zone}/${name}.go` };
586
+ }
587
+
588
+ // 应用功能的创建表单:直接读模块的 portal.json(input 给字段,value 给选项)
589
+ // 从框架的命令自省接口读操作参数:
590
+ // GET /s/<space>/c/command/<command>
591
+ // meta = { create: [{name,type,need}], _trans: { input: {字段: 中文名} }, … }
592
+ async function commandFields({ node, space, command, action = 'create' }) {
593
+ const r = await http(node, `/s/${encode(space)}/c/command/${command}`, { timeout: 12000, headers: { accept: 'application/json' } });
594
+ if (r.status !== 200) return [];
595
+ let env = {};
596
+ try { env = JSON.parse(r.text); } catch { return []; }
597
+ let meta = {};
598
+ try { meta = JSON.parse(env.meta?.[0] || '{}'); } catch { meta = {}; }
599
+ const labels = (meta._trans && meta._trans.input) || {};
600
+ const LABEL_EN = { title: 'Title', content: 'Content', name: 'Name', system_uid: 'System', uid: 'ID', index: 'Index', begin_time: 'Begin', end_time: 'End', status: 'Status', remark: 'Remark', desc: 'Description', phone: 'Phone', email: 'Email', address: 'Address', city_name: 'City', company_name: 'Company', project_name: 'Project' };
601
+ const spec = Array.isArray(meta[action]) ? meta[action] : [];
602
+ const fields = [];
603
+ for (const it of spec) {
604
+ const name = String(it.name || '');
605
+ if (!name) continue;
606
+ fields.push({
607
+ name,
608
+ key: name.replace(/_uid$/, ''),
609
+ label: String(labels[name] || it.title || name),
610
+ labelEn: String(LABEL_EN[name] || name),
611
+ type: it.type === 'select' || it.type === 'checkbox' || it.type === 'radio' ? 'select' : 'text',
612
+ options: Array.isArray(it.options) ? it.options : [],
613
+ required: it.need === 'must',
614
+ });
615
+ }
616
+ if (fields.length) return fields;
617
+ // meta 没给该动作的参数时,退回 list 的描述
618
+ let list = [];
619
+ try { list = JSON.parse(env.list?.[0] || '[]'); } catch { list = []; }
620
+ const SKIP = new Set(['uid', 'list', 'back', 'refresh', 'open', 'delete', 'remove']);
621
+ for (const it of list) {
622
+ const name = String(it.name || '');
623
+ if (!name || SKIP.has(name) || it.action || it.type === 'button') continue;
624
+ fields.push({
625
+ name,
626
+ key: name.replace(/_uid$/, ''),
627
+ label: String(labels[name] || it.title || it.label || name),
628
+ type: it.type === 'select' && Array.isArray(it.options) && it.options.length ? 'select' : 'text',
629
+ options: Array.isArray(it.options) ? it.options : [],
630
+ required: Boolean(it.required),
631
+ });
632
+ }
633
+ return fields;
634
+ }
635
+
636
+ export async function appFields({ node, space, command = '' } = {}) {
637
+ const n = (await listNodes()).find((x) => x.port === Number(node));
638
+ if (!n) throw new Error(`node ${node} not found`);
639
+ const mod = String(command).split('.')[2] || '';
640
+ if (!space || !mod) throw new Error('space and command are required');
641
+ // 空间没启动时自省会回 echo,先确保空间在跑
642
+ const raw = await http(node, `/s/${encode(space)}/c/${command}`, { timeout: 6000 }).catch(() => null);
643
+ if (raw && /"detail"\s*:/.test(String(raw.text || ''))) {
644
+ try { await startSpace({ node, space }); } catch { /* 起不来就按下面的兜底走 */ }
645
+ await new Promise((r) => setTimeout(r, 2500));
646
+ }
647
+ // 首选:直接问命令本身要「创建」的参数(/c/<command>?action=create)
648
+ const asked = await commandFields({ node, space, command }).catch(() => []);
649
+ if (asked.length) return asked.slice(0, 8);
650
+ const file = path.join(n.dir, 'usr', 'local', 'work', space, 'src', mod, 'portal.json');
651
+ let portal = {};
652
+ try { portal = JSON.parse(await fs.readFile(file, 'utf8')); } catch { return []; }
653
+ const input = portal.input || {};
654
+ const value = portal.value || {};
655
+ const out = [];
656
+ for (const [name, label] of Object.entries(input)) {
657
+ if (/^My /.test(name)) continue;
658
+ if (/(_count|_space|_role)$/.test(name)) continue;
659
+ const key = name.replace(/_uid$/, '');
660
+ const opts = value[key] || value[name];
661
+ const options = opts && typeof opts === 'object'
662
+ ? Object.entries(opts).filter(([k]) => k !== 'style').map(([k, v]) => ({ value: k, label: String(v) }))
663
+ : [];
664
+ out.push({ name, key, label: String(label), type: options.length ? 'select' : 'text', options, required: /_uid$/.test(name) });
665
+ }
666
+ // 去掉引用型噪声:有 xxx_title/xxx_name 时就不再单独要 xxx_uid;状态由服务端算
667
+ const titles = new Set(out.filter((f) => /_(title|name)$/.test(f.name)).map((f) => f.name.replace(/_(title|name)$/, '')));
668
+ const kept = out.filter((f) => {
669
+ if (/_status$/.test(f.name)) return false;
670
+ const root = f.name.replace(/_uid$/, '');
671
+ if (/_uid$/.test(f.name) && titles.has(root)) return false;
672
+ return true;
673
+ });
674
+ return kept.slice(0, 8);
675
+ }