@leeoohoo/ui-apps-devkit 0.1.6 → 0.1.7
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/package.json +1 -1
- package/src/commands/install.js +66 -18
- package/src/commands/validate.js +48 -0
- package/src/sandbox/server.js +18 -7
- package/templates/basic/README.md +2 -0
- package/templates/basic/docs/CHATOS_UI_APPS_AI_CONTRIBUTIONS.md +2 -0
- package/templates/notepad/README.md +6 -0
- package/templates/notepad/docs/CHATOS_UI_APPS_AI_CONTRIBUTIONS.md +2 -0
package/package.json
CHANGED
package/src/commands/install.js
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
1
2
|
import os from 'os';
|
|
2
3
|
import path from 'path';
|
|
4
|
+
import crypto from 'crypto';
|
|
3
5
|
|
|
4
6
|
import { copyDir, ensureDir, isDirectory, rmForce, sanitizeDirComponent } from '../lib/fs.js';
|
|
5
7
|
import { loadDevkitConfig } from '../lib/config.js';
|
|
6
8
|
import { findPluginDir, loadPluginManifest } from '../lib/plugin.js';
|
|
7
9
|
import { STATE_ROOT_DIRNAME } from '../lib/state-constants.js';
|
|
8
10
|
|
|
11
|
+
function createActionId(prefix) {
|
|
12
|
+
const base = typeof prefix === 'string' && prefix.trim() ? prefix.trim() : 'action';
|
|
13
|
+
const short = crypto.randomUUID().split('-')[0];
|
|
14
|
+
return `${base}-${Date.now().toString(36)}-${short}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function logWith(filePath, entry) {
|
|
18
|
+
if (!filePath) return;
|
|
19
|
+
try {
|
|
20
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
21
|
+
fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`, 'utf8');
|
|
22
|
+
} catch {
|
|
23
|
+
// ignore logging failures
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
function defaultStateDir(hostApp) {
|
|
10
28
|
const app = typeof hostApp === 'string' && hostApp.trim() ? hostApp.trim() : 'chatos';
|
|
11
29
|
return path.join(os.homedir(), STATE_ROOT_DIRNAME, app);
|
|
@@ -28,29 +46,59 @@ function copyPluginDir(srcDir, destDir) {
|
|
|
28
46
|
|
|
29
47
|
export async function cmdInstall({ flags }) {
|
|
30
48
|
const { config } = loadDevkitConfig(process.cwd());
|
|
31
|
-
const
|
|
32
|
-
const { pluginId, name, version } = loadPluginManifest(pluginDir);
|
|
33
|
-
|
|
49
|
+
const actionId = createActionId('devkit_install');
|
|
34
50
|
const hostApp = String(flags['host-app'] || flags.hostApp || 'chatos').trim() || 'chatos';
|
|
35
51
|
const stateDir = String(flags['state-dir'] || flags.stateDir || defaultStateDir(hostApp)).trim();
|
|
36
|
-
|
|
52
|
+
const logFile = String(flags['log-file'] || flags.logFile || path.join(stateDir, 'devkit-install-log.jsonl')).trim();
|
|
53
|
+
const log = (level, message, meta, err) => {
|
|
54
|
+
const entry = {
|
|
55
|
+
ts: new Date().toISOString(),
|
|
56
|
+
level,
|
|
57
|
+
message,
|
|
58
|
+
actionId,
|
|
59
|
+
pid: process.pid,
|
|
60
|
+
meta: meta && typeof meta === 'object' ? meta : meta === undefined ? undefined : { value: meta },
|
|
61
|
+
error: err?.message || (err ? String(err) : undefined),
|
|
62
|
+
};
|
|
63
|
+
logWith(logFile, entry);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
if (!stateDir) throw new Error('stateDir is required');
|
|
68
|
+
log('info', 'devkit.install.start', { hostApp, stateDir, logFile });
|
|
69
|
+
const pluginDir = findPluginDir(process.cwd(), flags['plugin-dir'] || flags.pluginDir || config?.pluginDir);
|
|
70
|
+
const { pluginId, name, version } = loadPluginManifest(pluginDir);
|
|
71
|
+
|
|
72
|
+
const pluginsRoot = path.join(stateDir, 'ui_apps', 'plugins');
|
|
73
|
+
ensureDir(pluginsRoot);
|
|
37
74
|
|
|
38
|
-
|
|
39
|
-
|
|
75
|
+
const dirName = sanitizeDirComponent(pluginId);
|
|
76
|
+
if (!dirName) throw new Error(`Invalid plugin id: ${pluginId}`);
|
|
40
77
|
|
|
41
|
-
|
|
42
|
-
|
|
78
|
+
const destDir = path.join(pluginsRoot, dirName);
|
|
79
|
+
const replaced = isDirectory(destDir);
|
|
80
|
+
copyPluginDir(pluginDir, destDir);
|
|
43
81
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
82
|
+
log('info', 'devkit.install.complete', {
|
|
83
|
+
pluginId,
|
|
84
|
+
name,
|
|
85
|
+
version,
|
|
86
|
+
pluginDir,
|
|
87
|
+
destDir,
|
|
88
|
+
replaced,
|
|
89
|
+
});
|
|
47
90
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.log(
|
|
93
|
+
`Installed: ${pluginId} (${name}@${version})\n` +
|
|
94
|
+
` -> ${destDir}\n` +
|
|
95
|
+
` replaced: ${replaced}\n` +
|
|
96
|
+
` log: ${logFile}\n\n` +
|
|
97
|
+
`Open ChatOS -> 应用 -> 刷新(同 id 覆盖生效)。`
|
|
98
|
+
);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
log('error', 'devkit.install.failed', {}, err);
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
55
103
|
}
|
|
56
104
|
|
package/src/commands/validate.js
CHANGED
|
@@ -18,11 +18,52 @@ function statSizeSafe(filePath) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function collectSymlinks(rootDir) {
|
|
22
|
+
const root = typeof rootDir === 'string' ? rootDir.trim() : '';
|
|
23
|
+
if (!root) return [];
|
|
24
|
+
const out = [];
|
|
25
|
+
const walk = (dir) => {
|
|
26
|
+
let entries = [];
|
|
27
|
+
try {
|
|
28
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
29
|
+
} catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
entries.forEach((entry) => {
|
|
33
|
+
if (!entry || !entry.name) return;
|
|
34
|
+
const fullPath = path.join(dir, entry.name);
|
|
35
|
+
let stat;
|
|
36
|
+
try {
|
|
37
|
+
stat = fs.lstatSync(fullPath);
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (stat.isSymbolicLink()) {
|
|
42
|
+
out.push(fullPath);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (stat.isDirectory()) {
|
|
46
|
+
walk(fullPath);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
walk(root);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
21
54
|
export async function cmdValidate({ flags }) {
|
|
22
55
|
const { config } = loadDevkitConfig(process.cwd());
|
|
23
56
|
const pluginDir = findPluginDir(process.cwd(), flags['plugin-dir'] || flags.pluginDir || config?.pluginDir);
|
|
24
57
|
|
|
25
58
|
const { manifestPath, manifest } = loadPluginManifest(pluginDir);
|
|
59
|
+
const warnings = [];
|
|
60
|
+
const warn = (message) => warnings.push(message);
|
|
61
|
+
|
|
62
|
+
const symlinks = collectSymlinks(pluginDir);
|
|
63
|
+
if (symlinks.length > 0) {
|
|
64
|
+
const rel = symlinks.map((entry) => path.relative(pluginDir, entry) || entry);
|
|
65
|
+
warn(`Symlink detected inside plugin dir (may bypass path boundaries): ${rel.join(', ')}`);
|
|
66
|
+
}
|
|
26
67
|
|
|
27
68
|
const manifestSize = statSizeSafe(manifestPath);
|
|
28
69
|
assert(manifestSize <= 256 * 1024, `plugin.json too large (>256KiB): ${manifestSize} bytes`);
|
|
@@ -108,6 +149,13 @@ export async function cmdValidate({ flags }) {
|
|
|
108
149
|
}
|
|
109
150
|
}
|
|
110
151
|
|
|
152
|
+
if (warnings.length > 0) {
|
|
153
|
+
warnings.forEach((message) => {
|
|
154
|
+
// eslint-disable-next-line no-console
|
|
155
|
+
console.warn(`WARN: ${message}`);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
111
159
|
// eslint-disable-next-line no-console
|
|
112
160
|
console.log(`OK: ${path.relative(process.cwd(), pluginDir)} (apps=${apps.length})`);
|
|
113
161
|
}
|
package/src/sandbox/server.js
CHANGED
|
@@ -668,10 +668,12 @@ function htmlPage() {
|
|
|
668
668
|
border-radius:14px;
|
|
669
669
|
overflow:hidden;
|
|
670
670
|
box-shadow: 0 18px 60px rgba(0,0,0,0.18);
|
|
671
|
+
z-index: 1305;
|
|
672
|
+
pointer-events: auto;
|
|
671
673
|
}
|
|
672
674
|
#promptsPanelHeader { padding: 10px 12px; display:flex; align-items:center; justify-content:space-between; border-bottom: 1px solid var(--ds-panel-border); }
|
|
673
675
|
#promptsPanelBody { padding: 10px 12px; overflow:auto; display:flex; flex-direction:column; gap:10px; }
|
|
674
|
-
#promptsFab { position: fixed; right: 16px; bottom: 16px; width: 44px; height: 44px; border-radius: 999px; display:flex; align-items:center; justify-content:center; }
|
|
676
|
+
#promptsFab { position: fixed; right: 16px; bottom: 16px; width: 44px; height: 44px; border-radius: 999px; display:flex; align-items:center; justify-content:center; z-index: 1305; pointer-events: auto; }
|
|
675
677
|
.card { border: 1px solid var(--ds-panel-border); border-radius: 12px; padding: 10px; background: var(--ds-panel-bg); }
|
|
676
678
|
.row { display:flex; gap:10px; }
|
|
677
679
|
.toolbar-group { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
@@ -867,8 +869,8 @@ function htmlPage() {
|
|
|
867
869
|
<div class="card">
|
|
868
870
|
<div class="section-title">Paths</div>
|
|
869
871
|
<pre id="mcpPaths" class="mono"></pre>
|
|
870
|
-
<label for="mcpWorkdir">
|
|
871
|
-
<input id="mcpWorkdir" type="text" placeholder="留空则使用默认
|
|
872
|
+
<label for="mcpWorkdir">ProjectRoot Override (optional)</label>
|
|
873
|
+
<input id="mcpWorkdir" type="text" placeholder="留空则使用默认 projectRoot" />
|
|
872
874
|
</div>
|
|
873
875
|
<div class="card">
|
|
874
876
|
<div class="section-title">Model</div>
|
|
@@ -1146,9 +1148,15 @@ const appendMcpOutput = (label, payload) => {
|
|
|
1146
1148
|
mcpOutput.scrollTop = mcpOutput.scrollHeight;
|
|
1147
1149
|
};
|
|
1148
1150
|
|
|
1151
|
+
const resolveMcpPaths = () => {
|
|
1152
|
+
const projectRootOverride = mcpWorkdir ? String(mcpWorkdir.value || '').trim() : '';
|
|
1153
|
+
if (!projectRootOverride) return sandboxPaths;
|
|
1154
|
+
return { ...sandboxPaths, projectRoot: projectRootOverride };
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1149
1157
|
const refreshMcpPaths = () => {
|
|
1150
1158
|
if (!mcpPaths) return;
|
|
1151
|
-
mcpPaths.textContent = formatJson(
|
|
1159
|
+
mcpPaths.textContent = formatJson(resolveMcpPaths());
|
|
1152
1160
|
};
|
|
1153
1161
|
|
|
1154
1162
|
const refreshMcpConfigHint = async () => {
|
|
@@ -1193,9 +1201,11 @@ const runMcpTest = async () => {
|
|
|
1193
1201
|
systemPrompt: mcpSystem ? String(mcpSystem.value || '').trim() : '',
|
|
1194
1202
|
disableTools: Boolean(mcpDisableTools?.checked),
|
|
1195
1203
|
};
|
|
1196
|
-
const
|
|
1197
|
-
if (
|
|
1198
|
-
payload.callMeta = {
|
|
1204
|
+
const projectRootOverride = mcpWorkdir ? String(mcpWorkdir.value || '').trim() : '';
|
|
1205
|
+
if (projectRootOverride) {
|
|
1206
|
+
payload.callMeta = {
|
|
1207
|
+
chatos: { uiApp: { projectRoot: projectRootOverride } },
|
|
1208
|
+
};
|
|
1199
1209
|
}
|
|
1200
1210
|
setMcpStatus('Sending...');
|
|
1201
1211
|
appendMcpOutput('request', payload);
|
|
@@ -1327,6 +1337,7 @@ if (btnLlmClear) btnLlmClear.addEventListener('click', () => saveLlmConfig({ cle
|
|
|
1327
1337
|
if (btnMcpTest) btnMcpTest.addEventListener('click', () => setMcpPanelOpen(!isMcpPanelOpen()));
|
|
1328
1338
|
if (btnHalfApp) btnHalfApp.addEventListener('click', () => toggleCompactSurface());
|
|
1329
1339
|
if (btnMcpClose) btnMcpClose.addEventListener('click', () => setMcpPanelOpen(false));
|
|
1340
|
+
if (mcpWorkdir) mcpWorkdir.addEventListener('input', () => refreshMcpPaths());
|
|
1330
1341
|
if (btnMcpClear)
|
|
1331
1342
|
btnMcpClear.addEventListener('click', () => {
|
|
1332
1343
|
if (mcpOutput) mcpOutput.textContent = '';
|
|
@@ -34,6 +34,7 @@ npm run dev
|
|
|
34
34
|
|
|
35
35
|
- `plugin/plugin.json`:`apps[i].entry.type` 必须是 `module`,且 `path` 在插件目录内。
|
|
36
36
|
- `plugin/plugin.json`:可选 `apps[i].entry.compact.path`,用于 compact UI。
|
|
37
|
+
- 安全:插件目录内尽量避免 symlink(`npm run validate` 会提示),以免路径边界与打包行为不一致。
|
|
37
38
|
- `mount()`:返回卸载函数并清理事件/订阅;滚动放在应用内部,固定内容用 `slots.header`。
|
|
38
39
|
- 主题:用 `host.theme.*` 与 `--ds-*` tokens;避免硬编码颜色。
|
|
39
40
|
- 宿主能力:先判断 `host.bridge.enabled`,非宿主环境要可降级运行。
|
|
@@ -49,6 +50,7 @@ npm run dev
|
|
|
49
50
|
2) **后端调用 LLM**:在 `plugin/backend/index.mjs` 里通过 `ctx.llm.complete()` 调用模型;前端用 `host.backend.invoke('llmComplete', { input })` 触发。
|
|
50
51
|
|
|
51
52
|
说明:本地沙箱默认是 mock;可通过右上角 `AI Config` 配置 `API Key / Base URL / Model ID` 后启用真实模型调用,并用于测试应用 MCP(需配置 `ai.mcp`)。
|
|
53
|
+
补充:沙箱默认把 `_meta.workdir` 设为 `dataDir`;如需模拟其它工作目录,可在 `AI Config` 里设置 `Workdir` 覆盖(支持 `$dataDir/$pluginDir/$projectRoot`)。
|
|
52
54
|
|
|
53
55
|
## 安装到本机 ChatOS
|
|
54
56
|
|
|
@@ -9,6 +9,11 @@ npm install
|
|
|
9
9
|
npm run dev
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
## 本地沙箱 AI Config(可选)
|
|
13
|
+
|
|
14
|
+
- 右上角 `AI Config` 可配置 `API Key / Base URL / Model ID`,用于测试真实模型调用(以及 MCP 调用)。
|
|
15
|
+
- 沙箱默认把 `_meta.workdir` 设为 `dataDir`;如需模拟其它工作目录,可在 `AI Config` 里设置 `Workdir` 覆盖(支持 `$dataDir/$pluginDir/$projectRoot`)。
|
|
16
|
+
|
|
12
17
|
## 目录说明
|
|
13
18
|
|
|
14
19
|
- `plugin/plugin.json`:插件清单(应用列表、入口、后端、AI 贡献)
|
|
@@ -29,6 +34,7 @@ npm run dev
|
|
|
29
34
|
|
|
30
35
|
- `plugin/plugin.json`:`apps[i].entry.type` 必须是 `module`,且 `path` 在插件目录内。
|
|
31
36
|
- `plugin/plugin.json`:可选 `apps[i].entry.compact.path`,用于 compact UI。
|
|
37
|
+
- 安全:插件目录内尽量避免 symlink(`npm run validate` 会提示),以免路径边界与打包行为不一致。
|
|
32
38
|
- `mount()`:返回卸载函数并清理事件/订阅;滚动放在应用内部,固定内容用 `slots.header`。
|
|
33
39
|
- 主题:用 `host.theme.*` 与 `--ds-*` tokens;避免硬编码颜色。
|
|
34
40
|
- 宿主能力:先判断 `host.bridge.enabled`,非宿主环境要可降级运行。
|