@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leeoohoo/ui-apps-devkit",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "ChatOS UI Apps DevKit (CLI + templates + sandbox) for building installable ChatOS UI Apps plugins.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 pluginDir = findPluginDir(process.cwd(), flags['plugin-dir'] || flags.pluginDir || config?.pluginDir);
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
- if (!stateDir) throw new Error('stateDir is required');
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
- const pluginsRoot = path.join(stateDir, 'ui_apps', 'plugins');
39
- ensureDir(pluginsRoot);
75
+ const dirName = sanitizeDirComponent(pluginId);
76
+ if (!dirName) throw new Error(`Invalid plugin id: ${pluginId}`);
40
77
 
41
- const dirName = sanitizeDirComponent(pluginId);
42
- if (!dirName) throw new Error(`Invalid plugin id: ${pluginId}`);
78
+ const destDir = path.join(pluginsRoot, dirName);
79
+ const replaced = isDirectory(destDir);
80
+ copyPluginDir(pluginDir, destDir);
43
81
 
44
- const destDir = path.join(pluginsRoot, dirName);
45
- const replaced = isDirectory(destDir);
46
- copyPluginDir(pluginDir, destDir);
82
+ log('info', 'devkit.install.complete', {
83
+ pluginId,
84
+ name,
85
+ version,
86
+ pluginDir,
87
+ destDir,
88
+ replaced,
89
+ });
47
90
 
48
- // eslint-disable-next-line no-console
49
- console.log(
50
- `Installed: ${pluginId} (${name}@${version})\n` +
51
- ` -> ${destDir}\n` +
52
- ` replaced: ${replaced}\n\n` +
53
- `Open ChatOS -> 应用 -> 刷新(同 id 覆盖生效)。`
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
 
@@ -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
  }
@@ -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">Workdir Override (optional)</label>
871
- <input id="mcpWorkdir" type="text" placeholder="留空则使用默认 workdir" />
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(sandboxPaths);
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 workdirOverride = mcpWorkdir ? String(mcpWorkdir.value || '').trim() : '';
1197
- if (workdirOverride) {
1198
- payload.callMeta = { workdir: workdirOverride };
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
 
@@ -85,6 +85,8 @@
85
85
 
86
86
  同时,宿主默认注入 `_meta.workdir`(默认等于 `dataDir`;如需指定其它目录,可在 `ai.mcp.callMeta.workdir` 覆盖)。
87
87
 
88
+ > DevKit sandbox:右上角 `AI Config` 支持设置 `Workdir` 覆盖;留空即使用 `dataDir`(也支持 `$dataDir/$pluginDir/$projectRoot`)。
89
+
88
90
  示例(`plugin.json`):
89
91
 
90
92
  ```json
@@ -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`,非宿主环境要可降级运行。
@@ -85,6 +85,8 @@
85
85
 
86
86
  同时,宿主默认注入 `_meta.workdir`(默认等于 `dataDir`;如需指定其它目录,可在 `ai.mcp.callMeta.workdir` 覆盖)。
87
87
 
88
+ > DevKit sandbox:右上角 `AI Config` 支持设置 `Workdir` 覆盖;留空即使用 `dataDir`(也支持 `$dataDir/$pluginDir/$projectRoot`)。
89
+
88
90
  示例(`plugin.json`):
89
91
 
90
92
  ```json