lantern-harness 0.1.0 → 0.1.2
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/CHANGELOG.md +26 -1
- package/README.md +10 -13
- package/builtin/session/lh-spaces.mjs +3 -0
- package/cli/acp-server.mjs +15 -1
- package/cli/ensure-dist.mjs +1 -3
- package/cli/lantern.mjs +25 -9
- package/cli/spaces.mjs +187 -33
- package/dist/500.html +2 -2
- package/dist/_astro/{Base.CFN876O3.css → Base.B9f_iFAZ.css} +1 -1
- package/dist/_astro/{CaseGenerateApp.oU5Exw2b.js → CaseGenerateApp.Br4uKjun.js} +1 -1
- package/dist/_astro/{CasesLibrary.CDcqlb6I.js → CasesLibrary.CpPoVQ4P.js} +1 -1
- package/dist/_astro/{ChatApp.tMPYvFMU.js → ChatApp.CYSPlSNP.js} +1 -1
- package/dist/_astro/{KnowledgeLibrary.DwYJQYZT.js → KnowledgeLibrary.DZinlAWn.js} +1 -1
- package/dist/_astro/{NavLibraries.BwtNpUK6.js → NavLibraries.EayBsg5g.js} +1 -1
- package/dist/_astro/{PluginsPage.tLehAvrG.js → PluginsPage.BNzpPg2H.js} +1 -1
- package/dist/_astro/PluginsPage.CY61glLU.js +1 -0
- package/dist/_astro/{PromptsLibrary.BpetRzj6.js → PromptsLibrary.DfrrCjs7.js} +1 -1
- package/dist/_astro/{SettingsDialog.Dc2xMiIZ.js → SettingsDialog.DC0e7580.js} +1 -1
- package/dist/_astro/SpaceSwitcher.Yy2w8MDF.js +7 -0
- package/dist/_astro/i18n.BVvhz-vw.js +7 -0
- package/dist/_astro/{open-docs-in-session.Bx75APfu.js → open-docs-in-session.D3JCwMWB.js} +1 -1
- package/dist/_astro/{settings.Cb-d0EJe.js → settings.BoQ0e0m1.js} +2 -2
- package/dist/cases/generate/index.html +3 -3
- package/dist/cases/library/index.html +3 -3
- package/dist/docs/changelog/index.html +25 -4
- package/dist/docs/install/index.html +6 -6
- package/dist/docs/manual/index.html +2 -2
- package/dist/index.html +3 -3
- package/dist/knowledge/index.html +3 -3
- package/dist/knowledge//345/212/237/350/203/275/346/265/213/350/257/225/347/224/250/344/276/213/347/274/226/345/206/231/350/247/204/350/214/203/index.html +2 -2
- package/dist/plugins/index.html +3 -3
- package/dist/prompts/index.html +3 -3
- package/dist/requirements/index.html +2 -2
- package/dist/templates/index.html +2 -2
- package/dist/templates//345/212/237/350/203/275/346/265/213/350/257/225/346/212/245/345/221/212//345/212/237/350/203/275/346/265/213/350/257/225/346/212/245/345/221/212/346/250/241/346/235/277/index.html +2 -2
- package/dist/templates//346/200/247/350/203/275/346/265/213/350/257/225/346/212/245/345/221/212//346/200/247/350/203/275/346/265/213/350/257/225/346/212/245/345/221/212/346/250/241/346/235/277/index.html +2 -2
- package/dist/templates//346/200/247/350/203/275/346/265/213/350/257/225/347/224/250/344/276/213//346/200/247/350/203/275/346/265/213/350/257/225/347/224/250/344/276/213/346/250/241/346/235/277/index.html +2 -2
- package/dist/templates//346/216/245/345/217/243/346/265/213/350/257/225/346/212/245/345/221/212//346/216/245/345/217/243/346/265/213/350/257/225/346/212/245/345/221/212/346/250/241/346/235/277/index.html +2 -2
- package/dist/templates//350/207/252/345/212/250/345/214/226/346/265/213/350/257/225/347/224/250/344/276/213//350/207/252/345/212/250/345/214/226/346/265/213/350/257/225/347/224/250/344/276/213/346/250/241/346/235/277/index.html +2 -2
- package/docs//345/256/211/350/243/205/346/211/213/345/206/214.md +4 -4
- package/package.json +16 -7
- package/dist/_astro/PluginsPage.CgIpm4kx.js +0 -1
- package/dist/_astro/SpaceSwitcher.CC-GMa_K.js +0 -7
- package/dist/_astro/i18n.BWX_-rcS.js +0 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
# 版本日志
|
|
2
2
|
|
|
3
|
+
## [0.1.2] - 2026-10-18
|
|
4
|
+
|
|
5
|
+
### 变更
|
|
6
|
+
|
|
7
|
+
- 升级命令由 `lantern install [版本]` 改为 `lantern update` / `lantern update <版本>`,帮助与文档同步;`lantern install` 保留为兼容别名
|
|
8
|
+
- ACP `initialize` 的版本号改为读 `package.json`,不再随发版手改
|
|
9
|
+
|
|
10
|
+
### 修复
|
|
11
|
+
|
|
12
|
+
- 重新 `lantern init` 换了数据目录后,空间列表与「当前空间」仍指向上一个数据目录里的空间:注册表改为按池根分区(每条空间记 `root`,激活项记 `activeByRoot`),列表只看当前数据目录;旧版全局 `activeId` 与无 `root` 的条目在读取时按路径推断归属,向后兼容
|
|
13
|
+
- `lantern web` / `lantern status` 把生效的空间目录标成「当前空间」,不再和数据目录混为一谈
|
|
14
|
+
|
|
15
|
+
## [0.1.1] - 2026-10-18
|
|
16
|
+
|
|
17
|
+
### 变更
|
|
18
|
+
|
|
19
|
+
- 产品定位从「测试工程师工作台」调整为通用 AI 工作台:README 功能表收敛为 AI 工作台 / 会话记忆 / 插件库三项,用例生成、测试模板库、知识库管理等能力统一由插件库开启;`package.json` 的 `description` 同步
|
|
20
|
+
- `package.json` 的 `keywords` 去掉 `qa` / `测试` / `测试工作台` / `测试用例` / `用例生成`,补上 `ai-workbench` / `local-first` / `byok` / `plugins` / `knowledge-base` / `prompt-library` / `memory`,并加入中文关键词便于站内检索
|
|
21
|
+
|
|
22
|
+
### 修复
|
|
23
|
+
|
|
24
|
+
- 纯静态部署(GitHub Pages)没有本地服务时,侧栏顶部不再留一块空白:改为显示不可点击的「工作区不可用」占位;探测完成前保留骨架,避免首帧闪一下再变回空间名
|
|
25
|
+
|
|
3
26
|
## [0.1.0] - 2026-10-17
|
|
4
27
|
|
|
5
|
-
Lantern Harness
|
|
28
|
+
Lantern Harness 首个版本:AI 工作台。一份进程里隔离多套项目数据,内容库、插件、会话记忆与本地工具开箱可用。
|
|
6
29
|
|
|
7
30
|
### 新增
|
|
8
31
|
|
|
@@ -140,4 +163,6 @@ Lantern Harness 首个版本:测试工程师的本地 AI 工作台。一份进
|
|
|
140
163
|
- 开发服忽略打开内容库目录与空间元数据目录导致的整页热更新
|
|
141
164
|
- 文档与 CLI 统一 pnpm 优先:安装 / 卸载 / 版本列表优先调用 pnpm(未安装则回退 npm);README、安装手册、操作手册与贡献约定的开发命令改为 pnpm
|
|
142
165
|
|
|
166
|
+
[0.1.2]: https://www.npmjs.com/package/lantern-harness/v/0.1.2
|
|
167
|
+
[0.1.1]: https://www.npmjs.com/package/lantern-harness/v/0.1.1
|
|
143
168
|
[0.1.0]: https://www.npmjs.com/package/lantern-harness/v/0.1.0
|
package/README.md
CHANGED
|
@@ -2,20 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
> 提灯的人不问路。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
AI 工作台:对话与插件工具、需求 / 用例 / 知识 / 提示词内容库、模板库、会话记忆,数据全部留在本机。
|
|
6
6
|
|
|
7
7
|
仓库:[yychuiyan/lantern-harness](https://github.com/yychuiyan/lantern-harness) · 文档:[lantern.yychuiyan.com](https://lantern.yychuiyan.com/) · npm 包 `lantern-harness`,命令 `lantern` · [变更日志](https://lantern.yychuiyan.com/docs/changelog/)
|
|
8
8
|
|
|
9
9
|
## 功能
|
|
10
10
|
|
|
11
|
-
| 模块
|
|
12
|
-
|
|
|
13
|
-
| AI 工作台
|
|
14
|
-
|
|
|
15
|
-
|
|
|
16
|
-
| 知识库管理 | 测试规范 / 方法论 / 环境约定,磁盘 `知识库管理/`;列表管理、创建与 AI 生成;对话「@」可引用 |
|
|
17
|
-
| 会话记忆 | 五轨记忆(用户档案 / 全局事实 / 项目关键 / 项目日志 / 每日),待确认队列与工作台「记忆」Tab |
|
|
18
|
-
| 插件库 | 内置能力开关;扩展插件按内容库 / 生成能力 / 记忆与检索 / 开发工具分类;可让模型推荐能力建议 |
|
|
11
|
+
| 模块 | 说明 |
|
|
12
|
+
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| AI 工作台 | 多轮对话,流式输出,BYOK(自带密钥)直连模型;默认提示词按空间可改,可按插件挂载工具 |
|
|
14
|
+
| 会话记忆 | 五轨记忆(用户档案 / 全局事实 / 项目关键 / 项目日志 / 每日),待确认队列与工作台「记忆」Tab |
|
|
15
|
+
| 插件库 | 内置能力开关:内容库(需求 / 用例 / 提示词 / 知识库 / 模板)、生成能力、记忆与检索、开发工具;开启后侧栏出现对应入口,也可让模型推荐能力建议 |
|
|
19
16
|
|
|
20
17
|
模型配置(BYOK)、内容目录约定、插件用法见 **[操作手册](https://lantern.yychuiyan.com/docs/manual/)**。
|
|
21
18
|
|
|
@@ -31,7 +28,7 @@
|
|
|
31
28
|
npx lantern-harness
|
|
32
29
|
```
|
|
33
30
|
|
|
34
|
-
执行后会安装全局短命令 `lantern`,之后用 `lantern init` / `lantern web` 操作即可。升级用 `lantern
|
|
31
|
+
执行后会安装全局短命令 `lantern`,之后用 `lantern init` / `lantern web` 操作即可。升级用 `lantern update`。
|
|
35
32
|
|
|
36
33
|
2、 初始化数据空间(首次必做;可再次执行以更换数据空间):
|
|
37
34
|
|
|
@@ -64,9 +61,9 @@ lantern logs # 查看服务日志
|
|
|
64
61
|
lantern doctor # 检查运行环境与配置是否正常
|
|
65
62
|
lantern help # 显示操作命令列表
|
|
66
63
|
lantern version # 显示当前版本号
|
|
67
|
-
lantern list #
|
|
68
|
-
lantern
|
|
69
|
-
lantern
|
|
64
|
+
lantern list # 显示可用版本列表
|
|
65
|
+
lantern update # 更新到最新版本
|
|
66
|
+
lantern update 0.1.x # 更新到指定版本
|
|
70
67
|
lantern uninstall # 卸载本机 lantern 命令(保留数据空间)
|
|
71
68
|
lantern wipe # 删除当前数据空间(需确认;不可恢复)
|
|
72
69
|
lantern space list # 列出全部数据空间与当前项
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
resolveSpace,
|
|
38
38
|
sanitizeSpaceName,
|
|
39
39
|
setActiveSpace,
|
|
40
|
+
setRegistryRoot,
|
|
40
41
|
spaceSize,
|
|
41
42
|
} from '../../cli/spaces.mjs';
|
|
42
43
|
import { loadRunState, saveRunState } from '../../cli/user-config.mjs';
|
|
@@ -212,6 +213,8 @@ export function lhSpacesPlugin() {
|
|
|
212
213
|
configureServer(server) {
|
|
213
214
|
// 首次进入确定基准根;已切换过的空间不会被覆盖
|
|
214
215
|
ensureSpaceContext(server.config.root);
|
|
216
|
+
// 注册表按池根分区:先告诉它当前是哪个数据目录,才能只认自己的空间与激活项
|
|
217
|
+
setRegistryRoot(getBaseRoot());
|
|
215
218
|
// 新进程里 activeRoot 默认是公共空间:按注册表回到用户上次选的空间
|
|
216
219
|
syncActiveRoot();
|
|
217
220
|
|
package/cli/acp-server.mjs
CHANGED
|
@@ -13,10 +13,24 @@
|
|
|
13
13
|
* - session/update { sessionId, toolCall: { name, status, args?, result? } }
|
|
14
14
|
*/
|
|
15
15
|
import readline from 'node:readline';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
16
20
|
import { headlessAsk, loadHeadlessSettings } from './headless.mjs';
|
|
17
21
|
|
|
18
22
|
const PROTOCOL_VERSION = 1;
|
|
19
23
|
|
|
24
|
+
/** 与 package.json 保持同源,避免发版时漏改 */
|
|
25
|
+
const SERVER_VERSION = (() => {
|
|
26
|
+
try {
|
|
27
|
+
const pkg = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
28
|
+
return JSON.parse(fs.readFileSync(pkg, 'utf8')).version || '0.0.0';
|
|
29
|
+
} catch {
|
|
30
|
+
return '0.0.0';
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
|
|
20
34
|
function send(msg) {
|
|
21
35
|
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
22
36
|
}
|
|
@@ -74,7 +88,7 @@ export async function runAcpServer(opts) {
|
|
|
74
88
|
protocolVersion: PROTOCOL_VERSION,
|
|
75
89
|
serverInfo: {
|
|
76
90
|
name: 'lantern-harness-acp',
|
|
77
|
-
version:
|
|
91
|
+
version: SERVER_VERSION,
|
|
78
92
|
},
|
|
79
93
|
capabilities: {
|
|
80
94
|
sessions: true,
|
package/cli/ensure-dist.mjs
CHANGED
|
@@ -35,9 +35,7 @@ if (!hasAstro) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
const runner = pickRunner();
|
|
38
|
-
console.log(
|
|
39
|
-
`[lantern-harness] 未找到 dist/,正在执行 ${runner.cmd} ${runner.args.join(' ')} …`,
|
|
40
|
-
);
|
|
38
|
+
console.log(`[lantern-harness] 未找到 dist/,正在执行 ${runner.cmd} ${runner.args.join(' ')} …`);
|
|
41
39
|
const r = spawnSync(runner.cmd, runner.args, {
|
|
42
40
|
cwd: root,
|
|
43
41
|
stdio: 'inherit',
|
package/cli/lantern.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
createSpace,
|
|
37
37
|
discoverSpaces,
|
|
38
38
|
exportSpaceToZip,
|
|
39
|
+
forgetRegistryRoot,
|
|
39
40
|
importSpaceFromZip,
|
|
40
41
|
listSpaces,
|
|
41
42
|
PUBLIC_SPACE_LABEL,
|
|
@@ -43,6 +44,7 @@ import {
|
|
|
43
44
|
renameSpace,
|
|
44
45
|
resolveSpace,
|
|
45
46
|
setActiveSpace,
|
|
47
|
+
setRegistryRoot,
|
|
46
48
|
} from './spaces.mjs';
|
|
47
49
|
|
|
48
50
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -143,8 +145,9 @@ function printHelp() {
|
|
|
143
145
|
lantern doctor 检查运行环境与配置
|
|
144
146
|
lantern help 显示本帮助
|
|
145
147
|
lantern version 显示当前版本号
|
|
146
|
-
lantern list
|
|
147
|
-
lantern
|
|
148
|
+
lantern list 显示可用版本列表
|
|
149
|
+
lantern update 更新到最新版本
|
|
150
|
+
lantern update <版本> 更新到指定版本
|
|
148
151
|
lantern uninstall 卸载本机 lantern(保留数据空间)
|
|
149
152
|
lantern wipe 删除当前数据空间(需确认)
|
|
150
153
|
|
|
@@ -205,6 +208,7 @@ function requireDataRoot(dirOverride) {
|
|
|
205
208
|
if (dirOverride) {
|
|
206
209
|
const dataRoot = path.resolve(dirOverride);
|
|
207
210
|
if (!fs.existsSync(dataRoot)) fs.mkdirSync(dataRoot, { recursive: true });
|
|
211
|
+
setRegistryRoot(dataRoot);
|
|
208
212
|
return dataRoot;
|
|
209
213
|
}
|
|
210
214
|
const result = resolveConfiguredDataRoot();
|
|
@@ -212,6 +216,8 @@ function requireDataRoot(dirOverride) {
|
|
|
212
216
|
printDataRootUnavailable(result);
|
|
213
217
|
process.exit(1);
|
|
214
218
|
}
|
|
219
|
+
// 注册表按池根分区:换过数据空间后,只认这个目录下的空间
|
|
220
|
+
setRegistryRoot(result.dataRoot);
|
|
215
221
|
return result.dataRoot;
|
|
216
222
|
}
|
|
217
223
|
|
|
@@ -239,6 +245,8 @@ async function cmdInit() {
|
|
|
239
245
|
}
|
|
240
246
|
prepareSpacesRoot(spacesRoot);
|
|
241
247
|
saveUserConfig({ dataRoot: spacesRoot, host: '127.0.0.1', port: 4321 });
|
|
248
|
+
// 换数据目录后注册表要按新池根分区,别把上一个目录的空间与激活项带过来
|
|
249
|
+
setRegistryRoot(spacesRoot);
|
|
242
250
|
console.log('');
|
|
243
251
|
console.log('数据目录已关联:');
|
|
244
252
|
console.log(` ${spacesRoot}`);
|
|
@@ -267,7 +275,7 @@ async function runForegroundServer(dataRoot) {
|
|
|
267
275
|
console.log('');
|
|
268
276
|
console.log('Lantern Harness 已启动');
|
|
269
277
|
console.log(` 地址:${url}`);
|
|
270
|
-
console.log(`
|
|
278
|
+
console.log(` 当前空间:${root}`);
|
|
271
279
|
console.log(' 按 Ctrl+C 停止');
|
|
272
280
|
console.log('');
|
|
273
281
|
}
|
|
@@ -278,7 +286,7 @@ function cmdStart({ foreground = false, dir = null } = {}) {
|
|
|
278
286
|
if (alive) {
|
|
279
287
|
console.log(`服务已在运行(pid ${alive.pid})`);
|
|
280
288
|
console.log(` 地址:${alive.url}`);
|
|
281
|
-
console.log(`
|
|
289
|
+
console.log(` 当前空间:${alive.dataRoot}`);
|
|
282
290
|
return;
|
|
283
291
|
}
|
|
284
292
|
|
|
@@ -311,7 +319,7 @@ function cmdStart({ foreground = false, dir = null } = {}) {
|
|
|
311
319
|
console.log('');
|
|
312
320
|
console.log('Lantern Harness 已在后台启动');
|
|
313
321
|
console.log(` 地址:${state.url}`);
|
|
314
|
-
console.log(`
|
|
322
|
+
console.log(` 当前空间:${state.dataRoot}`);
|
|
315
323
|
console.log(` 日志:${logPath}`);
|
|
316
324
|
console.log(' 停止:lantern stop');
|
|
317
325
|
console.log('');
|
|
@@ -388,6 +396,7 @@ function cmdStatus() {
|
|
|
388
396
|
if (run) {
|
|
389
397
|
console.log(`状态:运行中(pid ${run.pid})`);
|
|
390
398
|
console.log(`地址:${run.url}`);
|
|
399
|
+
console.log(`当前空间:${run.dataRoot}`);
|
|
391
400
|
} else {
|
|
392
401
|
console.log('状态:未运行');
|
|
393
402
|
}
|
|
@@ -510,10 +519,10 @@ function cmdList() {
|
|
|
510
519
|
}
|
|
511
520
|
}
|
|
512
521
|
|
|
513
|
-
function
|
|
522
|
+
function cmdUpdate(version) {
|
|
514
523
|
const pm = getPackageManager();
|
|
515
524
|
const spec = version ? `${PKG_NAME}@${version}` : `${PKG_NAME}@latest`;
|
|
516
|
-
console.log(
|
|
525
|
+
console.log(version ? `正在更新到 ${spec} …` : `正在更新到最新版本(${spec})…`);
|
|
517
526
|
const r = spawnSync(pm, pmGlobalArgs(pm, 'install', spec), {
|
|
518
527
|
stdio: 'inherit',
|
|
519
528
|
shell: process.platform === 'win32',
|
|
@@ -526,8 +535,12 @@ function cmdInstall(version) {
|
|
|
526
535
|
/** CLI 里的「基准根」:与网页端一致 —— 已记住的数据空间 */
|
|
527
536
|
function spaceBaseRoot() {
|
|
528
537
|
const result = resolveConfiguredDataRoot();
|
|
529
|
-
if (result.ok)
|
|
538
|
+
if (result.ok) {
|
|
539
|
+
setRegistryRoot(result.dataRoot);
|
|
540
|
+
return result.dataRoot;
|
|
541
|
+
}
|
|
530
542
|
// 尚未 init 时,池根退化到当前目录,保证 space 命令本身可用
|
|
543
|
+
setRegistryRoot(process.cwd());
|
|
531
544
|
return process.cwd();
|
|
532
545
|
}
|
|
533
546
|
|
|
@@ -823,6 +836,7 @@ async function cmdWipe() {
|
|
|
823
836
|
if (run) cmdStop();
|
|
824
837
|
|
|
825
838
|
fs.rmSync(resolved, { recursive: true, force: true });
|
|
839
|
+
forgetRegistryRoot(resolved);
|
|
826
840
|
clearDataRootFromConfig();
|
|
827
841
|
console.log('数据空间已删除,配置中的关联已清除。');
|
|
828
842
|
console.log('如需继续使用,请使用 lantern init 初始化或选择已有数据空间。');
|
|
@@ -933,8 +947,10 @@ async function main() {
|
|
|
933
947
|
case 'list':
|
|
934
948
|
cmdList();
|
|
935
949
|
break;
|
|
950
|
+
case 'update':
|
|
951
|
+
// 旧文档 / 老脚本里的 install 仍可用,输出与 update 一致
|
|
936
952
|
case 'install':
|
|
937
|
-
|
|
953
|
+
cmdUpdate(args[0]);
|
|
938
954
|
break;
|
|
939
955
|
case 'uninstall':
|
|
940
956
|
cmdUninstall();
|
package/cli/spaces.mjs
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 文件位置:`~/.config/lantern-harness/spaces.json`(与 CLI 的 config.json 同目录)
|
|
5
5
|
*
|
|
6
|
+
* 一份文件里按「池根」分区:每个空间条目记着自己属于哪个数据目录(`root`),
|
|
7
|
+
* 激活项也按池根分别记录(`activeByRoot`)。`lantern init` 换了数据空间之后,
|
|
8
|
+
* 列表只看新池根下的空间,不会把上一个数据目录里的空间一并列出来。
|
|
9
|
+
* 旧版全局 `activeId` / 无 `root` 的条目在读取时按路径推断归属,向后兼容。
|
|
10
|
+
*
|
|
6
11
|
* 「公共空间」是内建虚拟项,不入库:用户数据目录下直接是 `公共空间/`,源码仓库则在 `空间池/公共空间/`。
|
|
7
12
|
* 新建空间会自动归进同一层(与公共空间同级)。界面路径隐去「空间池」。
|
|
8
13
|
* 与自定义空间同构,因此可以整体拷贝 / 备份。
|
|
@@ -81,6 +86,46 @@ export function getSpacesPath() {
|
|
|
81
86
|
return path.join(getUserConfigDir(), 'spaces.json');
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
/** 当前池根:由 CLI / 服务端在知道数据目录后告知,供无 baseRoot 参数的调用(激活项)使用 */
|
|
90
|
+
let registryRoot = '';
|
|
91
|
+
|
|
92
|
+
/** @param {string} [root] */
|
|
93
|
+
export function setRegistryRoot(root) {
|
|
94
|
+
if (root) registryRoot = path.resolve(root);
|
|
95
|
+
return registryRoot;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 条目所属池根:优先读登记值,其次按路径推断(`<根>/空间池/<名称>` 或 `<根>/<名称>`) */
|
|
99
|
+
function inferEntryRoot(p) {
|
|
100
|
+
const abs = path.resolve(String(p || ''));
|
|
101
|
+
const marker = `${path.sep}${SPACE_POOL_DIR}${path.sep}`;
|
|
102
|
+
const idx = abs.lastIndexOf(marker);
|
|
103
|
+
return idx > 0 ? abs.slice(0, idx) : '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sameRoot(a, b) {
|
|
107
|
+
if (!a || !b) return false;
|
|
108
|
+
return path.resolve(a) === path.resolve(b);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** abs 是否落在 dir 内(含 dir 本身) */
|
|
112
|
+
function isInsideDir(abs, dir) {
|
|
113
|
+
if (!abs || !dir) return false;
|
|
114
|
+
const rel = path.relative(path.resolve(dir), path.resolve(abs));
|
|
115
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function rootKey(root) {
|
|
119
|
+
const abs = root || registryRoot;
|
|
120
|
+
return abs ? path.resolve(abs) : '';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function activeIdForRoot(reg, root) {
|
|
124
|
+
const key = rootKey(root);
|
|
125
|
+
const id = key ? reg.activeByRoot?.[key] : '';
|
|
126
|
+
return String(id || PUBLIC_SPACE_ID);
|
|
127
|
+
}
|
|
128
|
+
|
|
84
129
|
/**
|
|
85
130
|
* 新建空间的默认父目录:与公共空间同级(用户数据目录 = 选中的文件夹;源码仓库 = 空间池)。
|
|
86
131
|
* @param {string} [baseRoot] 池根;缺省用 cwd
|
|
@@ -89,6 +134,13 @@ export function defaultManagedParent(baseRoot) {
|
|
|
89
134
|
return poolPath(baseRoot || process.cwd());
|
|
90
135
|
}
|
|
91
136
|
|
|
137
|
+
/** 登记条目时推导它属于哪个池根:优先用调用方给的池根,其次从父目录反推 */
|
|
138
|
+
function resolveEntryRoot(baseRoot, parent) {
|
|
139
|
+
return path.resolve(
|
|
140
|
+
baseRoot || inferEntryRoot(parent) || parent || registryRoot || process.cwd(),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
92
144
|
/**
|
|
93
145
|
* 注册表里的绝对路径失效时(整个仓库/数据目录被改名或搬移)按清单身份在新池根下重定位。
|
|
94
146
|
* 只认清单 id / 名称都对得上的目录,避免把同名但无关的文件夹当成原空间。
|
|
@@ -120,16 +172,36 @@ function rebaseMissingSpacePath(abs, root, pool, entry) {
|
|
|
120
172
|
|
|
121
173
|
/**
|
|
122
174
|
* 按当前池布局改写注册表路径:用户目录把 `空间池/X` 抬到 `X`;源码仓库把误摊的 `X` 收回 `空间池/X`。
|
|
175
|
+
*
|
|
176
|
+
* 只改写「属于本池根」的条目。其它池根的条目原样保留 —— 除非它登记的池根已经不存在
|
|
177
|
+
* (整个数据目录被改名 / 搬移),这时按清单身份把它接到当前池根下,继续可用。
|
|
123
178
|
* @param {string} [baseRoot]
|
|
124
179
|
*/
|
|
125
180
|
export function relocateSpaceRegistryPaths(baseRoot) {
|
|
126
|
-
const root = path.resolve(baseRoot || process.cwd());
|
|
181
|
+
const root = path.resolve(baseRoot || registryRoot || process.cwd());
|
|
127
182
|
const pool = poolPath(root);
|
|
128
183
|
const nestedPrefix = path.join(root, SPACE_POOL_DIR);
|
|
129
|
-
const reg = loadSpacesRegistry();
|
|
184
|
+
const reg = loadSpacesRegistry(root);
|
|
130
185
|
let dirty = false;
|
|
131
186
|
for (const s of reg.spaces) {
|
|
132
187
|
const abs = path.resolve(s.path);
|
|
188
|
+
const own = s.root ? path.resolve(s.root) : root;
|
|
189
|
+
if (!sameRoot(own, root)) {
|
|
190
|
+
// 别的数据空间:池根还在就不要动,否则换空间时会串台
|
|
191
|
+
if (isDir(own)) continue;
|
|
192
|
+
// 池根整个被改名 / 搬走:按清单身份接到当前池根
|
|
193
|
+
let next = isDir(abs) ? null : rebaseMissingSpacePath(abs, root, pool, s);
|
|
194
|
+
if (!next && s.external) {
|
|
195
|
+
const byName = path.resolve(pool, s.name);
|
|
196
|
+
if (byName !== abs && isDir(byName)) next = byName;
|
|
197
|
+
}
|
|
198
|
+
if (next) {
|
|
199
|
+
s.path = next;
|
|
200
|
+
s.root = root;
|
|
201
|
+
dirty = true;
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
133
205
|
let next = null;
|
|
134
206
|
if (!isDir(abs)) {
|
|
135
207
|
// 旧根被改名 / 搬走:先按清单身份找回来
|
|
@@ -176,34 +248,63 @@ function newSpaceId() {
|
|
|
176
248
|
|
|
177
249
|
function emptyRegistry() {
|
|
178
250
|
// managedParent 不再落进注册表:它由池根推导,存下来只会在换机器后变成死路径
|
|
179
|
-
return { activeId: PUBLIC_SPACE_ID, spaces: [] };
|
|
251
|
+
return { activeId: PUBLIC_SPACE_ID, activeByRoot: {}, spaces: [] };
|
|
180
252
|
}
|
|
181
253
|
|
|
182
254
|
/**
|
|
183
255
|
* 读取注册表;任何损坏都退回空注册表(宁可重置元数据,也不能让服务起不来)。
|
|
184
256
|
*
|
|
185
257
|
* 不再读取/推断 `managedParent`:空间位置一律由池根推导(见 `defaultManagedParent`)。
|
|
186
|
-
*
|
|
258
|
+
* 条目缺 `root` 时按路径推断;推断不出来的(老版本的外部纳入)归到当前池根,保持旧行为。
|
|
259
|
+
* @param {string} [root] 当前池根;缺省用 `setRegistryRoot` 记下的值
|
|
260
|
+
* @returns {{ activeId: string, activeByRoot: Record<string, string>, spaces: Array<{id:string,name:string,path:string,createdAt:number,root:string}> }}
|
|
187
261
|
*/
|
|
188
|
-
export function loadSpacesRegistry() {
|
|
262
|
+
export function loadSpacesRegistry(root) {
|
|
189
263
|
const fp = getSpacesPath();
|
|
190
264
|
if (!fs.existsSync(fp)) return emptyRegistry();
|
|
265
|
+
const cur = rootKey(root);
|
|
191
266
|
try {
|
|
192
267
|
const raw = JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
193
268
|
if (!raw || typeof raw !== 'object') return emptyRegistry();
|
|
194
269
|
const spaces = Array.isArray(raw.spaces)
|
|
195
270
|
? raw.spaces
|
|
196
|
-
.map((it) =>
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
271
|
+
.map((it) => {
|
|
272
|
+
const abs = path.resolve(String(it?.path || ''));
|
|
273
|
+
// 归属推断:登记值 → 路径里的「空间池」段 → 当前池根内(老版本的外部纳入)
|
|
274
|
+
// 都不成立时留空:不属于任何池根,不会被任何数据目录列出来
|
|
275
|
+
const own =
|
|
276
|
+
String(it?.root || '').trim() ||
|
|
277
|
+
inferEntryRoot(abs) ||
|
|
278
|
+
(cur && isInsideDir(abs, cur) ? cur : '');
|
|
279
|
+
return {
|
|
280
|
+
id: String(it?.id || '').trim(),
|
|
281
|
+
name: String(it?.name || '').trim(),
|
|
282
|
+
path: abs,
|
|
283
|
+
createdAt: Number(it?.createdAt) || 0,
|
|
284
|
+
// 用户手工确认纳入的文件夹:不写 space.json,保持目录原样
|
|
285
|
+
...(it?.external === true ? { external: true } : {}),
|
|
286
|
+
...(own ? { root: path.resolve(own) } : {}),
|
|
287
|
+
};
|
|
288
|
+
})
|
|
204
289
|
.filter((it) => it.id && it.name && it.path && it.path !== path.sep)
|
|
205
290
|
: [];
|
|
206
|
-
|
|
291
|
+
const activeByRoot = {};
|
|
292
|
+
const rawActive = raw.activeByRoot;
|
|
293
|
+
if (rawActive && typeof rawActive === 'object' && !Array.isArray(rawActive)) {
|
|
294
|
+
for (const [k, v] of Object.entries(rawActive)) {
|
|
295
|
+
const key = String(k || '').trim();
|
|
296
|
+
const id = String(v || '').trim();
|
|
297
|
+
if (!key || !id) continue;
|
|
298
|
+
activeByRoot[path.resolve(key)] = id;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// 旧版只有一个全局 activeId:归到它所属空间的那个池根,别让它跨数据目录生效
|
|
302
|
+
const legacyActive = String(raw.activeId || PUBLIC_SPACE_ID);
|
|
303
|
+
if (legacyActive !== PUBLIC_SPACE_ID) {
|
|
304
|
+
const owner = spaces.find((s) => s.id === legacyActive && s.root);
|
|
305
|
+
if (owner?.root && !activeByRoot[owner.root]) activeByRoot[owner.root] = legacyActive;
|
|
306
|
+
}
|
|
307
|
+
return { activeId: legacyActive, activeByRoot, spaces };
|
|
207
308
|
} catch {
|
|
208
309
|
return emptyRegistry();
|
|
209
310
|
}
|
|
@@ -212,9 +313,17 @@ export function loadSpacesRegistry() {
|
|
|
212
313
|
export function saveSpacesRegistry(reg) {
|
|
213
314
|
const dir = getUserConfigDir();
|
|
214
315
|
fs.mkdirSync(dir, { recursive: true });
|
|
316
|
+
const spaces = Array.isArray(reg.spaces) ? reg.spaces : [];
|
|
317
|
+
const activeByRoot = { ...(reg.activeByRoot || {}) };
|
|
318
|
+
for (const [k, v] of Object.entries(activeByRoot)) {
|
|
319
|
+
if (!k || !v) delete activeByRoot[k];
|
|
320
|
+
}
|
|
321
|
+
// activeId 只作旧版本兼容:写当前池根的激活项,读代码以 activeByRoot 为准
|
|
322
|
+
const cur = rootKey();
|
|
215
323
|
const next = {
|
|
216
|
-
activeId: String(reg.activeId || PUBLIC_SPACE_ID),
|
|
217
|
-
|
|
324
|
+
activeId: String((cur && activeByRoot[cur]) || reg.activeId || PUBLIC_SPACE_ID),
|
|
325
|
+
activeByRoot,
|
|
326
|
+
spaces,
|
|
218
327
|
};
|
|
219
328
|
fs.writeFileSync(getSpacesPath(), `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
220
329
|
return next;
|
|
@@ -376,8 +485,9 @@ function hasKnownContentDir(abs) {
|
|
|
376
485
|
* @param {{ managedParent?: string, baseRoot?: string, adopt?: string[] }} [opts]
|
|
377
486
|
*/
|
|
378
487
|
export function discoverSpaces(opts = {}) {
|
|
379
|
-
const reg = loadSpacesRegistry();
|
|
488
|
+
const reg = loadSpacesRegistry(opts.baseRoot);
|
|
380
489
|
const parent = path.resolve(opts.managedParent || defaultManagedParent(opts.baseRoot));
|
|
490
|
+
const ownerRoot = resolveEntryRoot(opts.baseRoot, parent);
|
|
381
491
|
const added = [];
|
|
382
492
|
const backfilled = [];
|
|
383
493
|
const skipped = [];
|
|
@@ -428,6 +538,7 @@ export function discoverSpaces(opts = {}) {
|
|
|
428
538
|
name: clean.name,
|
|
429
539
|
path: abs,
|
|
430
540
|
createdAt: Date.now(),
|
|
541
|
+
root: ownerRoot,
|
|
431
542
|
external: true,
|
|
432
543
|
};
|
|
433
544
|
reg.spaces.push(entry);
|
|
@@ -500,6 +611,7 @@ export function discoverSpaces(opts = {}) {
|
|
|
500
611
|
name: clean.name,
|
|
501
612
|
path: abs,
|
|
502
613
|
createdAt: (read.ok ? read.manifest.createdAt : 0) || Date.now(),
|
|
614
|
+
root: ownerRoot,
|
|
503
615
|
};
|
|
504
616
|
reg.spaces.push(entry);
|
|
505
617
|
usedIds.add(id);
|
|
@@ -524,10 +636,14 @@ export function discoverSpaces(opts = {}) {
|
|
|
524
636
|
* @param {string} baseRoot 池根
|
|
525
637
|
*/
|
|
526
638
|
export function listSpaces(baseRoot) {
|
|
527
|
-
|
|
528
|
-
|
|
639
|
+
const root = path.resolve(baseRoot);
|
|
640
|
+
setRegistryRoot(root);
|
|
641
|
+
relocateSpaceRegistryPaths(root);
|
|
642
|
+
const reg = loadSpacesRegistry(root);
|
|
643
|
+
// 只列本池根下的空间:换数据目录后不会串到上一个数据目录
|
|
644
|
+
const mine = reg.spaces.filter((s) => sameRoot(s.root, root));
|
|
529
645
|
// 公共空间也是选中文件夹里的一个目录,与自定义空间同构
|
|
530
|
-
const publicRoot = path.resolve(publicSpacePath(
|
|
646
|
+
const publicRoot = path.resolve(publicSpacePath(root));
|
|
531
647
|
const all = [
|
|
532
648
|
{
|
|
533
649
|
id: PUBLIC_SPACE_ID,
|
|
@@ -536,16 +652,17 @@ export function listSpaces(baseRoot) {
|
|
|
536
652
|
createdAt: 0,
|
|
537
653
|
isPublic: true,
|
|
538
654
|
},
|
|
539
|
-
...
|
|
655
|
+
...mine.map((s) => ({ ...s, isPublic: false })),
|
|
540
656
|
];
|
|
657
|
+
const activeId = activeIdForRoot(reg, root);
|
|
541
658
|
return {
|
|
542
|
-
baseRoot:
|
|
543
|
-
managedParent: defaultManagedParent(
|
|
544
|
-
activeId
|
|
659
|
+
baseRoot: root,
|
|
660
|
+
managedParent: defaultManagedParent(root),
|
|
661
|
+
activeId,
|
|
545
662
|
spaces: all.map((s) => ({
|
|
546
663
|
...s,
|
|
547
664
|
exists: isDir(s.path),
|
|
548
|
-
isActive: s.id ===
|
|
665
|
+
isActive: s.id === activeId,
|
|
549
666
|
})),
|
|
550
667
|
};
|
|
551
668
|
}
|
|
@@ -573,10 +690,11 @@ export function createSpace({ name, managedParent, baseRoot }) {
|
|
|
573
690
|
const clean = sanitizeSpaceName(name);
|
|
574
691
|
if (!clean.ok) return { ok: false, error: clean.error };
|
|
575
692
|
|
|
693
|
+
const parent = path.resolve(managedParent || defaultManagedParent(baseRoot));
|
|
694
|
+
setRegistryRoot(baseRoot || inferEntryRoot(parent));
|
|
576
695
|
const reg = loadSpacesRegistry();
|
|
577
696
|
if (nameTaken(reg, clean.name)) return { ok: false, error: `已存在同名空间「${clean.name}」` };
|
|
578
697
|
|
|
579
|
-
const parent = path.resolve(managedParent || defaultManagedParent(baseRoot));
|
|
580
698
|
const dir = path.join(parent, clean.name);
|
|
581
699
|
|
|
582
700
|
// 目录可能已存在(例如用户手工建过):不能贸然复用,否则会把无关内容当成数据空间
|
|
@@ -588,7 +706,13 @@ export function createSpace({ name, managedParent, baseRoot }) {
|
|
|
588
706
|
return { ok: false, error: `创建目录失败:${e instanceof Error ? e.message : String(e)}` };
|
|
589
707
|
}
|
|
590
708
|
|
|
591
|
-
const entry = {
|
|
709
|
+
const entry = {
|
|
710
|
+
id: newSpaceId(),
|
|
711
|
+
name: clean.name,
|
|
712
|
+
path: dir,
|
|
713
|
+
createdAt: Date.now(),
|
|
714
|
+
root: resolveEntryRoot(baseRoot, parent),
|
|
715
|
+
};
|
|
592
716
|
try {
|
|
593
717
|
writeSpaceManifest(dir, entry);
|
|
594
718
|
} catch (e) {
|
|
@@ -667,7 +791,13 @@ export function copySpace(id, nextName, baseRoot) {
|
|
|
667
791
|
return { ok: false, error: `复制失败:${e instanceof Error ? e.message : String(e)}` };
|
|
668
792
|
}
|
|
669
793
|
|
|
670
|
-
const entry = {
|
|
794
|
+
const entry = {
|
|
795
|
+
id: newSpaceId(),
|
|
796
|
+
name: clean.name,
|
|
797
|
+
path: dir,
|
|
798
|
+
createdAt: Date.now(),
|
|
799
|
+
root: resolveEntryRoot(baseRoot, parent),
|
|
800
|
+
};
|
|
671
801
|
try {
|
|
672
802
|
// 拷贝继承的是源空间的清单(含源 id),必须改写成新身份
|
|
673
803
|
writeSpaceManifest(dir, entry);
|
|
@@ -691,6 +821,9 @@ export function removeSpace({ id, purge = false, baseRoot }) {
|
|
|
691
821
|
|
|
692
822
|
const reg = loadSpacesRegistry();
|
|
693
823
|
reg.spaces = reg.spaces.filter((s) => s.id !== space.id);
|
|
824
|
+
for (const [key, val] of Object.entries(reg.activeByRoot || {})) {
|
|
825
|
+
if (val === space.id) delete reg.activeByRoot[key];
|
|
826
|
+
}
|
|
694
827
|
if (reg.activeId === space.id) reg.activeId = PUBLIC_SPACE_ID;
|
|
695
828
|
saveSpacesRegistry(reg);
|
|
696
829
|
|
|
@@ -744,16 +877,35 @@ export function assertLooksLikeSpace(dir, managedParent) {
|
|
|
744
877
|
return { ok: true };
|
|
745
878
|
}
|
|
746
879
|
|
|
747
|
-
/**
|
|
880
|
+
/** 设置当前空间(按池根分别持久化,重启后沿用) */
|
|
748
881
|
export function setActiveSpace(id) {
|
|
749
882
|
const reg = loadSpacesRegistry();
|
|
750
|
-
|
|
883
|
+
const wanted = String(id || PUBLIC_SPACE_ID);
|
|
884
|
+
const owner = reg.spaces.find((s) => s.id === wanted);
|
|
885
|
+
const key = rootKey() || owner?.root || '';
|
|
886
|
+
if (key) {
|
|
887
|
+
if (!reg.activeByRoot) reg.activeByRoot = {};
|
|
888
|
+
reg.activeByRoot[path.resolve(key)] = wanted;
|
|
889
|
+
}
|
|
890
|
+
reg.activeId = wanted;
|
|
751
891
|
saveSpacesRegistry(reg);
|
|
752
|
-
return
|
|
892
|
+
return wanted;
|
|
753
893
|
}
|
|
754
894
|
|
|
755
895
|
export function getActiveSpaceId() {
|
|
756
|
-
|
|
896
|
+
const reg = loadSpacesRegistry();
|
|
897
|
+
const key = rootKey();
|
|
898
|
+
if (!key) return String(reg.activeId || PUBLIC_SPACE_ID);
|
|
899
|
+
return String(reg.activeByRoot?.[key] || PUBLIC_SPACE_ID);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** 忘掉某个池根的全部登记(`lantern wipe` 删掉数据目录后调用,避免留下死路径) */
|
|
903
|
+
export function forgetRegistryRoot(root) {
|
|
904
|
+
const key = path.resolve(root);
|
|
905
|
+
const reg = loadSpacesRegistry(key);
|
|
906
|
+
reg.spaces = reg.spaces.filter((s) => !sameRoot(s.root, key));
|
|
907
|
+
if (reg.activeByRoot) delete reg.activeByRoot[key];
|
|
908
|
+
saveSpacesRegistry(reg);
|
|
757
909
|
}
|
|
758
910
|
|
|
759
911
|
// ── 文件系统工具 ──────────────────────────────────────────────
|
|
@@ -925,10 +1077,11 @@ export async function importSpaceFromZip({ zipFile, name, managedParent, baseRoo
|
|
|
925
1077
|
const clean = sanitizeSpaceName(name ?? manifest?.name ?? fallbackName);
|
|
926
1078
|
if (!clean.ok) return { ok: false, error: clean.error };
|
|
927
1079
|
|
|
1080
|
+
const parent = path.resolve(managedParent || defaultManagedParent(baseRoot));
|
|
1081
|
+
setRegistryRoot(baseRoot || inferEntryRoot(parent));
|
|
928
1082
|
const reg = loadSpacesRegistry();
|
|
929
1083
|
if (nameTaken(reg, clean.name)) return { ok: false, error: `已存在同名空间「${clean.name}」` };
|
|
930
1084
|
|
|
931
|
-
const parent = path.resolve(managedParent || defaultManagedParent(baseRoot));
|
|
932
1085
|
const dir = path.join(parent, clean.name);
|
|
933
1086
|
if (fs.existsSync(dir)) return { ok: false, error: `目录已存在:${dir}` };
|
|
934
1087
|
|
|
@@ -952,6 +1105,7 @@ export async function importSpaceFromZip({ zipFile, name, managedParent, baseRoo
|
|
|
952
1105
|
name: clean.name,
|
|
953
1106
|
path: dir,
|
|
954
1107
|
createdAt: Date.now(),
|
|
1108
|
+
root: resolveEntryRoot(baseRoot, parent),
|
|
955
1109
|
};
|
|
956
1110
|
writeSpaceManifest(dir, entry);
|
|
957
1111
|
|