@stofone/dsh-agent-roster 0.13.0 → 1.0.0

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/lib/clone.js ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * 把一个远端「档案空间」拉成一份本地副本。
3
+ *
4
+ * 为什么不能只拉 `dist/`:编译产物是一份自包含的快照,能读、不能改——
5
+ * 你改它,改的是快照,不是仓库里的任何一个源文件。要让"编辑"和源文件对得上,
6
+ * 拉下来的目录结构必须和仓库里的**同形**:`roles/<role>/AGENT.md`、
7
+ * `rosters/<id>/roster.yaml`、`dist/<id>-<ver>.json`,一条路径都不许改。
8
+ *
9
+ * 三种来源都收:
10
+ * - `space.yaml` 在(layout: monorepo):按目录树逐文件拉,收到 `dist/`;
11
+ * - `space.yaml` 在(layout: flat):只拉 `<id>/roster.yaml`;
12
+ * - `space.yaml` 不在(老仓库):按 flat 处理,并在本地副本上补一份带 `origin` 的 space.yaml。
13
+ *
14
+ * @module dsh-agent-roster/clone
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import nodePath from "node:path";
19
+
20
+ import { parseDocumentText } from "./parse.js";
21
+ import { SPACE_FILE_NAMES, SPACE_KIND, validateSpace } from "./space.js";
22
+
23
+ const RAW = "https://raw.githubusercontent.com";
24
+
25
+ /** 拉取并发度:单次 clone 的文件数在几十份量级,6 路够用且不冒犯对方。 */
26
+ export const CLONE_CONCURRENCY = 6;
27
+
28
+ /** 目录里的相对路径只允许这些形态:拼 URL 前先钉死,免得把 `..` 带进写盘。 */
29
+ const SAFE_RELATIVE = /^(?:\.[A-Za-z0-9._-]+|[A-Za-z0-9._-]+)(?:\/[A-Za-z0-9._-]+)*$/;
30
+
31
+ function joinUrl(base, relative) {
32
+ const encoded = relative
33
+ .split("/")
34
+ .map((segment) => encodeURIComponent(segment))
35
+ .join("/");
36
+ return `${base}${encoded}`;
37
+ }
38
+
39
+ async function fetchText(url, fetchImpl) {
40
+ const response = await fetchImpl(url, { headers: { accept: "application/json, text/plain, */*" } });
41
+ if (!response.ok) return { ok: false, status: response.status };
42
+ return { ok: true, text: await response.text() };
43
+ }
44
+
45
+ /** 角色名取自 `name@1.2.3` 这种引用;取不到就不猜。 */
46
+ function roleNameOf(reference) {
47
+ if (typeof reference !== "string") return null;
48
+ const at = reference.lastIndexOf("@");
49
+ return at > 0 ? reference.slice(0, at) : reference;
50
+ }
51
+
52
+ /**
53
+ * 从一份 roster 文档里收集它引用的角色名(成员 + 队长)。
54
+ *
55
+ * @param {object} roster
56
+ * @returns {string[]}
57
+ */
58
+ export function rolesOfRoster(roster) {
59
+ const names = new Set();
60
+ for (const member of Array.isArray(roster?.members) ? roster.members : []) {
61
+ const name = roleNameOf(member?.role);
62
+ if (name) names.add(name);
63
+ }
64
+ const captain = roster?.captain;
65
+ if (captain && typeof captain === "object") {
66
+ const name = roleNameOf(captain.role);
67
+ if (name) names.add(name);
68
+ }
69
+ return [...names].sort();
70
+ }
71
+
72
+ /**
73
+ * 拉一个空间到 `<rootDir>/<spaceId>/`。
74
+ *
75
+ * @param {{
76
+ * rootDir: string,
77
+ * spaceId: string,
78
+ * repo: string,
79
+ * ref?: string,
80
+ * path?: string,
81
+ * fetchImpl: (url: string, init?: object) => Promise<object>,
82
+ * concurrency?: number,
83
+ * }} input
84
+ * @returns {Promise<{ok: boolean, dir: string, layout: string, written: string[], skipped: string[], diagnostics: object[]}>}
85
+ */
86
+ export async function cloneSpace(input) {
87
+ const ref = input.ref ?? "main";
88
+ const originPath = input.path ?? "";
89
+ const base = `${RAW}/${input.repo}/${encodeURIComponent(ref)}/${originPath === "" ? "" : `${originPath}/`}`;
90
+ const fetchImpl = input.fetchImpl;
91
+ const concurrency = Math.max(1, Math.min(16, input.concurrency ?? CLONE_CONCURRENCY));
92
+ const diagnostics = [];
93
+ const written = [];
94
+ const skipped = [];
95
+
96
+ const read = async (relative) => {
97
+ try {
98
+ return await fetchText(joinUrl(base, relative), fetchImpl);
99
+ } catch (error) {
100
+ diagnostics.push({ severity: "error", code: "clone-fetch-failed", path: relative, file: relative, reason: `拉取失败:${String(error?.message ?? error)}` });
101
+ return { ok: false, status: 0 };
102
+ }
103
+ };
104
+
105
+ // 1. 读空间封面;没有就按 flat 老仓库处理。
106
+ let spaceFile = null;
107
+ let raw = null;
108
+ for (const name of SPACE_FILE_NAMES) {
109
+ const got = await read(name);
110
+ if (got.ok) {
111
+ spaceFile = name;
112
+ raw = got.text;
113
+ break;
114
+ }
115
+ if (got.status !== 404 && got.status !== undefined) {
116
+ diagnostics.push({ severity: "warn", code: "clone-space-unreadable", path: name, file: name, reason: `读取 ${name} 返回 ${got.status}。` });
117
+ }
118
+ }
119
+
120
+ let space = null;
121
+ let layout = "flat";
122
+ let rosterIds = [];
123
+ if (spaceFile !== null) {
124
+ const parsed = parseDocumentText(raw, { file: spaceFile, format: spaceFile.endsWith(".json") ? "json" : "yaml" });
125
+ if (!parsed.ok) {
126
+ return { ok: false, dir: nodePath.join(input.rootDir, input.spaceId), layout, written, skipped, diagnostics: [...diagnostics, ...parsed.diagnostics] };
127
+ }
128
+ const validated = validateSpace(parsed.value, { file: spaceFile, expectedId: null });
129
+ if (!validated.ok) {
130
+ return { ok: false, dir: nodePath.join(input.rootDir, input.spaceId), layout, written, skipped, diagnostics: [...diagnostics, ...validated.diagnostics] };
131
+ }
132
+ space = validated.space;
133
+ layout = space.layout;
134
+ rosterIds = space.rosters.map((item) => item.id);
135
+ } else {
136
+ // 没有封面:调用方必须给 ids(来自 registry 索引),否则无从下手。
137
+ rosterIds = Array.isArray(input.rosterIds) ? [...input.rosterIds] : [];
138
+ if (rosterIds.length === 0) {
139
+ diagnostics.push({
140
+ severity: "error",
141
+ code: "clone-no-space",
142
+ path: "",
143
+ file: null,
144
+ reason: `${input.repo}@${ref}${originPath === "" ? "" : `/${originPath}`} 没有 space.yaml,调用方也没有给档案 id 列表。`,
145
+ });
146
+ return { ok: false, dir: nodePath.join(input.rootDir, input.spaceId), layout, written, skipped, diagnostics };
147
+ }
148
+ }
149
+
150
+ const dir = nodePath.join(input.rootDir, input.spaceId);
151
+ const files = new Map();
152
+
153
+ // 2. 档案本体(外加能力要求这种"同目录兄弟文件")。
154
+ for (const id of rosterIds) {
155
+ const relative = layout === "monorepo" ? `rosters/${id}/roster.yaml` : `${id}/roster.yaml`;
156
+ const got = await read(relative);
157
+ if (!got.ok) {
158
+ skipped.push(relative);
159
+ diagnostics.push({
160
+ severity: "warn",
161
+ code: "clone-roster-missing",
162
+ path: relative,
163
+ file: relative,
164
+ reason: `${id} 在 ${relative} 处读不到(${got.status});本地副本会少这一份。`,
165
+ });
166
+ continue;
167
+ }
168
+ files.set(relative, got.text);
169
+ if (layout === "monorepo") {
170
+ const capabilities = await read(`rosters/${id}/capabilities.yaml`);
171
+ if (capabilities.ok) files.set(`rosters/${id}/capabilities.yaml`, capabilities.text);
172
+ }
173
+ }
174
+
175
+ // 3. monorepo:把被引用的角色连同片段一起拉下来,否则本地编译不出人设。
176
+ if (layout === "monorepo") {
177
+ const roles = new Set(space?.roles ?? []);
178
+ for (const [relative, text] of [...files]) {
179
+ if (!relative.endsWith("roster.yaml")) continue;
180
+ const parsed = parseDocumentText(text, { file: relative, format: "yaml" });
181
+ if (!parsed.ok) continue;
182
+ for (const name of rolesOfRoster(parsed.value)) roles.add(name);
183
+ }
184
+ for (const name of [...roles].sort()) {
185
+ const agentPath = `roles/${name}/AGENT.md`;
186
+ const got = await read(agentPath);
187
+ if (!got.ok) {
188
+ if (!skipped.includes(agentPath)) {
189
+ skipped.push(agentPath);
190
+ diagnostics.push({
191
+ severity: "warn",
192
+ code: "clone-role-missing",
193
+ path: agentPath,
194
+ file: agentPath,
195
+ reason: `角色 ${name} 读不到(${got.status});本地编译会缺这一份人设。`,
196
+ });
197
+ }
198
+ continue;
199
+ }
200
+ files.set(agentPath, got.text);
201
+ // 片段路径写在 frontmatter 里,只能读完才知道;一次多一跳,不猜。
202
+ for (const part of partPathsOf(got.text)) {
203
+ const partPath = part.startsWith("roles/") ? part : `roles/${name}/${part}`;
204
+ const piece = await read(partPath);
205
+ if (piece.ok) files.set(partPath, piece.text);
206
+ else skipped.push(partPath);
207
+ }
208
+ }
209
+ }
210
+
211
+ // 4. 落盘:先写文件,最后写空间封面(封面存在即代表这次 clone 完整)。
212
+ try {
213
+ for (const [relative, text] of files) {
214
+ if (!SAFE_RELATIVE.test(relative)) {
215
+ diagnostics.push({ severity: "error", code: "clone-unsafe-path", path: relative, file: relative, reason: "拒绝写入:路径不是安全的相对路径。" });
216
+ continue;
217
+ }
218
+ const target = nodePath.join(dir, ...relative.split("/"));
219
+ fs.mkdirSync(nodePath.dirname(target), { recursive: true });
220
+ fs.writeFileSync(target, text, "utf8");
221
+ written.push(relative);
222
+ }
223
+ const cover = spaceCover({
224
+ space,
225
+ spaceId: input.spaceId,
226
+ layout,
227
+ rosterIds,
228
+ origin: { repo: input.repo, ref, path: originPath },
229
+ });
230
+ fs.mkdirSync(dir, { recursive: true });
231
+ fs.writeFileSync(nodePath.join(dir, "space.yaml"), cover, "utf8");
232
+ written.push("space.yaml");
233
+ } catch (error) {
234
+ diagnostics.push({ severity: "error", code: "clone-write-failed", path: "", file: null, reason: `写盘失败:${String(error?.message ?? error)}` });
235
+ return { ok: false, dir, layout, written, skipped, diagnostics };
236
+ }
237
+
238
+ return {
239
+ ok: diagnostics.every((item) => item.severity !== "error"),
240
+ dir,
241
+ layout,
242
+ written,
243
+ skipped,
244
+ diagnostics,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * 从 AGENT.md 的 frontmatter 里取片段路径;解析不了就返回空数组(不猜)。
250
+ *
251
+ * @param {string} text
252
+ * @returns {string[]}
253
+ */
254
+ function partPathsOf(text) {
255
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
256
+ if (match === null) return [];
257
+ const lines = match[1].split(/\r?\n/);
258
+ const parts = [];
259
+ let inParts = false;
260
+ for (const line of lines) {
261
+ if (/^prompt:\s*$/.test(line)) continue;
262
+ if (/^\s+parts:\s*$/.test(line)) {
263
+ inParts = true;
264
+ continue;
265
+ }
266
+ if (inParts) {
267
+ const item = line.match(/^\s*-\s*(\S+)\s*$/);
268
+ if (item) {
269
+ parts.push(item[1].replace(/^["']|["']$/g, ""));
270
+ continue;
271
+ }
272
+ if (/^\s{0,2}\S/.test(line)) inParts = false;
273
+ }
274
+ }
275
+ return parts;
276
+ }
277
+
278
+ /**
279
+ * 生成本地副本的 `space.yaml`:远端的那份 + `origin`。
280
+ *
281
+ * @param {{space: object|null, spaceId: string, layout: string, rosterIds: string[], origin: {repo: string, ref: string, path: string}}} input
282
+ * @returns {string}
283
+ */
284
+ export function spaceCover(input) {
285
+ const title = input.space?.title ?? input.spaceId;
286
+ const description = input.space?.description ?? "";
287
+ const license = input.space?.license ?? "";
288
+ const homepage = input.space?.homepage ?? "";
289
+ const maintainers = input.space?.maintainers ?? [];
290
+ const roles = input.space?.roles ?? [];
291
+ const lines = ["kind: " + SPACE_KIND, `space: ${input.spaceId}`, `title: ${yamlScalar(title)}`];
292
+ if (description !== "") lines.push(`description: ${yamlScalar(description)}`);
293
+ if (license !== "") lines.push(`license: ${yamlScalar(license)}`);
294
+ if (homepage !== "") lines.push(`homepage: ${yamlScalar(homepage)}`);
295
+ if (maintainers.length > 0) {
296
+ lines.push("maintainers:");
297
+ for (const who of maintainers) lines.push(` - ${yamlScalar(who)}`);
298
+ }
299
+ lines.push(`layout: ${input.layout}`);
300
+ lines.push("origin:");
301
+ lines.push(` repo: ${yamlScalar(input.origin.repo)}`);
302
+ lines.push(` ref: ${yamlScalar(input.origin.ref)}`);
303
+ if (input.origin.path !== "") lines.push(` path: ${yamlScalar(input.origin.path)}`);
304
+ if (roles.length > 0) {
305
+ lines.push("roles:");
306
+ for (const name of roles) lines.push(` - ${yamlScalar(name)}`);
307
+ }
308
+ lines.push("rosters:");
309
+ for (const id of input.rosterIds) lines.push(` - id: ${id}`);
310
+ return `${lines.join("\n")}\n`;
311
+ }
312
+
313
+ /** YAML 标量:需要引号就加双引号并转义,不需要就原样写。 */
314
+ function yamlScalar(value) {
315
+ const text = String(value);
316
+ if (/^[A-Za-z0-9\u4e00-\u9fa5][^:{}[\],&*#?|\-<>=!%@`"']*$/.test(text) && text.trim() === text) return text;
317
+ return JSON.stringify(text);
318
+ }
package/lib/library.js CHANGED
@@ -59,10 +59,10 @@ export function collectLibrary(ctx, config, meta) {
59
59
  stats: scan.stats,
60
60
  rosters: scan.rosters.map((roster) => serializeRoster(roster, scan.diagnostics)),
61
61
  // 面板抬头要显示"这份库属于哪个组织",只给 id 与标题,不带成员细节。
62
- organization:
63
- scan.organization === null || scan.organization === undefined
62
+ space:
63
+ scan.space === null || scan.space === undefined
64
64
  ? null
65
- : { id: scan.organization.organization, title: scan.organization.title ?? "" },
65
+ : { id: scan.space.id, title: scan.space.title ?? "" },
66
66
  diagnostics: scan.diagnostics,
67
67
  });
68
68
  }
package/lib/registry.js CHANGED
@@ -29,11 +29,11 @@ import { compileRosterAt, rosterViewOf, scanRoles } from "./compile2.js";
29
29
  import { sha256Hex } from "./digest.js";
30
30
  import { MEMBER_FILE_NAMES } from "./format.js";
31
31
  import {
32
- ORGANIZATION_FILE_NAMES,
33
- checkOrganization,
34
- organizationDiagnostics,
35
- validateOrganization,
36
- } from "./organization.js";
32
+ SPACE_FILE_NAMES,
33
+ checkSpace,
34
+ spaceDiagnostics,
35
+ validateSpace,
36
+ } from "./space.js";
37
37
  import { parseDocumentText } from "./parse.js";
38
38
  import { validateRoster } from "./validate.js";
39
39
 
@@ -82,9 +82,9 @@ export function scanRosterLibrary({ rootDir, knownToolPolicies = null }) {
82
82
  const capabilities = {};
83
83
  /** 档案 id → 能力要求的文件名(诊断要指到具体那份文件)。 */
84
84
  const capabilityFiles = {};
85
- /** 组织档案(organization.yaml)与它的交叉核对结果。 */
86
- let organization = null;
87
- let organizationFile = null;
85
+ /** 组织档案(space.yaml)与它的交叉核对结果。 */
86
+ let space = null;
87
+ let spaceFile = null;
88
88
  const stats = {
89
89
  total: 0,
90
90
  ok: 0,
@@ -123,8 +123,8 @@ export function scanRosterLibrary({ rootDir, knownToolPolicies = null }) {
123
123
  if (child.name.startsWith(".") || SKIP_DIRS.has(child.name) || child.isSymbolicLink()) continue;
124
124
 
125
125
  if (!child.isDirectory()) {
126
- // 库根上的 organization.yaml 是合法的:它是这座库的目录页,不是散落的档案。
127
- if (ORGANIZATION_FILE_NAMES.includes(child.name)) continue;
126
+ // 库根上的 space.yaml 是合法的:它是这座库的目录页,不是散落的档案。
127
+ if (SPACE_FILE_NAMES.includes(child.name)) continue;
128
128
  // 散落的 *.yaml 曾经是另一种布局;现在只认目录,必须明确告知而不是忽略。
129
129
  diagnostics.push({
130
130
  severity: "warn",
@@ -191,15 +191,15 @@ export function scanRosterLibrary({ rootDir, knownToolPolicies = null }) {
191
191
  }
192
192
 
193
193
  // 组织档案在目录扫完之后读:它的交叉核对需要知道磁盘上到底有哪些档案。
194
- const wantedOrganization = loadOrganization(rootDir, nodePath.basename(rootDir));
195
- organization = wantedOrganization.organization;
196
- organizationFile = wantedOrganization.file;
197
- diagnostics.push(...wantedOrganization.diagnostics);
198
- const organizationCheck = checkOrganization(
199
- organization,
194
+ const wantedSpace = loadSpace(rootDir, nodePath.basename(rootDir));
195
+ space = wantedSpace.space;
196
+ spaceFile = wantedSpace.file;
197
+ diagnostics.push(...wantedSpace.diagnostics);
198
+ const spaceCheck = checkSpace(
199
+ space,
200
200
  rosters.map((item) => item.id),
201
201
  );
202
- diagnostics.push(...organizationDiagnostics(organization, organizationCheck, organizationFile ?? ORGANIZATION_FILE_NAMES[0]));
202
+ diagnostics.push(...spaceDiagnostics(space, spaceCheck, spaceFile ?? SPACE_FILE_NAMES[0]));
203
203
 
204
204
  if (stats.total > 0 && rosters.length === 0) {
205
205
  diagnostics.push({
@@ -248,9 +248,9 @@ export function scanRosterLibrary({ rootDir, knownToolPolicies = null }) {
248
248
  rosters,
249
249
  capabilities,
250
250
  capabilityFiles,
251
- organization,
252
- organizationFile,
253
- organizationCheck,
251
+ space,
252
+ spaceFile,
253
+ spaceCheck,
254
254
  stats,
255
255
  diagnostics,
256
256
  };
@@ -368,22 +368,22 @@ function loadDocument(found, expectedId, { knownToolPolicies }, rawText = null)
368
368
  * @param {string} dirName 目录名(同时也是期望的 `roster` 字段)
369
369
  * @returns {{ capabilities: object | null, file: string | null, diagnostics: object[] }}
370
370
  */
371
- export function loadOrganization(rootDir, dirName) {
371
+ export function loadSpace(rootDir, dirName) {
372
372
  let names;
373
373
  try {
374
374
  names = readdirSync(rootDir);
375
375
  } catch {
376
- return { organization: null, file: null, diagnostics: [] };
376
+ return { space: null, file: null, diagnostics: [] };
377
377
  }
378
- const present = ORGANIZATION_FILE_NAMES.filter((name) => names.includes(name));
379
- if (present.length === 0) return { organization: null, file: null, diagnostics: [] };
378
+ const present = SPACE_FILE_NAMES.filter((name) => names.includes(name));
379
+ if (present.length === 0) return { space: null, file: null, diagnostics: [] };
380
380
 
381
381
  const diagnostics = [];
382
382
  const relative = present[0];
383
383
  if (present.length > 1) {
384
384
  diagnostics.push({
385
385
  severity: "warn",
386
- code: "multiple-organization-files",
386
+ code: "multiple-space-files",
387
387
  path: "",
388
388
  file: relative,
389
389
  reason:
@@ -403,17 +403,17 @@ export function loadOrganization(rootDir, dirName) {
403
403
  file: relative,
404
404
  reason: `文件不可读:${messageOf(error)}`,
405
405
  });
406
- return { organization: null, file: relative, diagnostics };
406
+ return { space: null, file: relative, diagnostics };
407
407
  }
408
408
 
409
409
  const format = relative.endsWith(".json") ? "json" : "yaml";
410
410
  const parsed = parseDocumentText(raw, { file: relative, format });
411
- if (!parsed.ok) return { organization: null, file: relative, diagnostics: [...diagnostics, ...parsed.diagnostics] };
411
+ if (!parsed.ok) return { space: null, file: relative, diagnostics: [...diagnostics, ...parsed.diagnostics] };
412
412
 
413
- const validated = validateOrganization(parsed.value, { file: relative, expectedId: dirName });
413
+ const validated = validateSpace(parsed.value, { file: relative, expectedId: dirName, allowOrigin: true });
414
414
  diagnostics.push(...validated.diagnostics);
415
- if (!validated.ok) return { organization: null, file: relative, diagnostics };
416
- return { organization: validated.organization, file: relative, diagnostics };
415
+ if (!validated.ok) return { space: null, file: relative, diagnostics };
416
+ return { space: validated.space, file: relative, diagnostics };
417
417
  }
418
418
 
419
419
  export function loadCapabilities(dirPath, dirName) {
package/lib/render.js CHANGED
@@ -89,11 +89,11 @@ export function renderRosterList(scan) {
89
89
  const { entries, stats, rootDir } = scan;
90
90
  const lines = [];
91
91
  lines.push(`档案库 ${rootDir}`);
92
- const organization = scan.organization ?? null;
93
- if (organization !== null) {
94
- const check = scan.organizationCheck ?? { declared: 0, present: 0, missing: [], undeclared: [], archived: [] };
95
- lines.push(`组织 ${organization.id} ${organization.title}`);
96
- if (organization.description !== "") lines.push(` ${organization.description}`);
92
+ const space = scan.space ?? null;
93
+ if (space !== null) {
94
+ const check = scan.spaceCheck ?? { declared: 0, present: 0, missing: [], undeclared: [], archived: [] };
95
+ lines.push(`空间 ${space.id} ${space.title}`);
96
+ if (space.description !== "") lines.push(` ${space.description}`);
97
97
  const parts = [
98
98
  `登记 ${check.declared}`,
99
99
  `在库 ${check.present}`,
@@ -101,7 +101,7 @@ export function renderRosterList(scan) {
101
101
  `未登记 ${check.undeclared.length}`,
102
102
  ];
103
103
  if (check.archived.length > 0) parts.push(`已归档 ${check.archived.length}`);
104
- if (organization.maintainers.length > 0) parts.push(`维护者 ${organization.maintainers.join(", ")}`);
104
+ if (space.maintainers.length > 0) parts.push(`维护者 ${space.maintainers.join(", ")}`);
105
105
  lines.push(` ${parts.join(" · ")}`);
106
106
  }
107
107
  if (entries.length === 0) {
package/lib/service.js CHANGED
@@ -14,6 +14,7 @@ import nodePath from "node:path";
14
14
 
15
15
  import { globalRosterRoot } from "./home.js";
16
16
  import { readDeployedCaptains, verifyCaptain } from "./captain.js";
17
+ import { cloneSpace } from "./clone.js";
17
18
  import { collectLibrary, listWorkspaces } from "./library.js";
18
19
  import { CACHE_DIR_NAME, fetchCollection, loadRegistryIndex } from "./remote.js";
19
20
  import { rosterDocumentText } from "./serialize.js";
@@ -428,6 +429,52 @@ export async function installCollection(config, options) {
428
429
  };
429
430
  }
430
431
 
432
+ /**
433
+ * 把一个远端 collection 对应的**整个空间**拉成本地副本。
434
+ *
435
+ * 与 `installCollection` 的区别不是"多拉几份",而是**布局**:
436
+ * 拉下来的目录和仓库同形(`roles/` + `rosters/` + `dist/`),
437
+ * 所以本地编辑的就是源仓库里的那个文件;`installCollection` 只摊平单份档案,
438
+ * 适合"我只要这一份、不打算往回改"的场景。
439
+ *
440
+ * @param {object} config
441
+ * @param {{ collectionId: string, source?: string, spaceId?: string, overwrite?: boolean, fetchImpl?: typeof fetch, cacheDir?: string }} options
442
+ * @returns {Promise<object>}
443
+ */
444
+ export async function installSpace(config, options) {
445
+ const panel = await loadRemotePanel(config, { fetchImpl: options.fetchImpl, cacheDir: options.cacheDir });
446
+ const collection = panel.collections.find(
447
+ (item) => item.id === options.collectionId && (options.source === undefined || item.origin?.source === options.source),
448
+ );
449
+ if (collection === undefined) {
450
+ return {
451
+ ok: false,
452
+ code: "collection-not-found",
453
+ known: panel.collections.map((item) => item.id),
454
+ diagnostics: panel.diagnostics,
455
+ };
456
+ }
457
+ const roots = listLibraryRoots({ rosterDirs: config.rosterDirs ?? [], workspaces: [] });
458
+ const root = roots[0] ?? null;
459
+ if (root === null) return { ok: false, code: "no-library-root", diagnostics: panel.diagnostics };
460
+
461
+ const spaceId = options.spaceId ?? collection.id;
462
+ const cloned = await cloneSpace({
463
+ rootDir: root.rootDir,
464
+ spaceId,
465
+ repo: collection.repo,
466
+ ref: collection.ref,
467
+ path: collection.path,
468
+ rosterIds: collection.rosters,
469
+ fetchImpl: options.fetchImpl ?? fetch,
470
+ });
471
+ return {
472
+ ...cloned,
473
+ collection: { id: collection.id, title: collection.title, repo: collection.repo, ref: collection.ref, path: collection.path },
474
+ rootDir: root.rootDir,
475
+ };
476
+ }
477
+
431
478
  /**
432
479
  * 远程档案源(公共 registry)的投影。
433
480
  *