341 lines
21 KiB
TypeScript
341 lines
21 KiB
TypeScript
import path from 'node:path';
|
||
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
||
import {
|
||
createProjectConfig,
|
||
buildResponsibilityPrompt,
|
||
getDefaultSuperpowersEnabled,
|
||
isProjectTemplateId,
|
||
projectTemplates,
|
||
YOUTH_PLAIN_LANGUAGE_SKILL_ID,
|
||
validateAgentNames,
|
||
type ProjectAgentConfig,
|
||
type ProjectConfig,
|
||
type ProjectTemplateId,
|
||
} from '../../shared/project-config';
|
||
|
||
export const PROJECT_CONFIG_PATH = '.niancode/project.json';
|
||
|
||
function versionOwnerForTemplate(templateId: ProjectTemplateId): string {
|
||
if (templateId === 'game-development') return 'game-design';
|
||
return 'product-planning';
|
||
}
|
||
|
||
function initialVersionDocument(templateId: ProjectTemplateId, now: string): string {
|
||
const owner = versionOwnerForTemplate(templateId);
|
||
return `# Project Version
|
||
|
||
Current: v0.1.0
|
||
Status: active
|
||
Version Owner: ${owner}
|
||
Started At: ${now}
|
||
Task Plan: TASKS.md
|
||
Summary: docs/versions/v0.1.0/VERSION_SUMMARY.md
|
||
Previous: none
|
||
`;
|
||
}
|
||
|
||
function initialTaskDocument(templateId: ProjectTemplateId): string {
|
||
const owner = versionOwnerForTemplate(templateId);
|
||
const isGameTemplate = templateId === 'game-development';
|
||
const versionGoal = isGameTemplate
|
||
? '建立一个可试玩的最小游戏循环,并完成第一批有来源、可预览、可审核的游戏素材候选。'
|
||
: '定义 v0.1.0 的最小可验证目标。';
|
||
const nowTasks = isGameTemplate
|
||
? `- [ ] TASK-001 明确当前版本目标
|
||
- Owner: ${owner}
|
||
- Status: todo
|
||
- Acceptance: GDD.md 写清玩家目标、核心循环、首个可试玩结果和当前边界
|
||
- Evidence: pending
|
||
|
||
- [ ] TASK-002 建立素材清单和接入策略
|
||
- Owner: game-art
|
||
- Status: todo
|
||
- Acceptance: ASSET_PLAN.md、ART_GUIDE.md 和素材目录已准备;每个缺口都有来源、授权、预览和审核证据规则
|
||
- Evidence: pending
|
||
|
||
- [ ] TASK-003 确认 2D/3D 渲染路径
|
||
- Owner: game-development
|
||
- Status: todo
|
||
- Acceptance: TECH_STACK.md 写清 2D Phaser 3.90.0 或 3D Three.js + TypeScript、浏览器目标和首个可玩验证方式
|
||
- Evidence: pending`
|
||
: `- [ ] TASK-001 明确当前版本目标
|
||
- Owner: ${owner}
|
||
- Status: todo
|
||
- Acceptance: 当前版本范围、验收和受影响文档已写清
|
||
- Evidence: pending`;
|
||
return `# TASKS
|
||
|
||
Project Version: v0.1.0
|
||
Document Revision: 1
|
||
Last Updated By: ${owner}
|
||
|
||
> 所有 Agent 共同读取和维护本文件。专业事实写入角色文档;这里保留任务状态、Owner、验收、证据摘要、阻塞和链接。
|
||
|
||
## Version Goal
|
||
|
||
- ${versionGoal}
|
||
|
||
## Now
|
||
|
||
${nowTasks}
|
||
|
||
## Next
|
||
|
||
- None
|
||
|
||
## Blocked
|
||
|
||
- None
|
||
|
||
## Version Proposals
|
||
|
||
- None
|
||
|
||
## Done
|
||
|
||
- None
|
||
`;
|
||
}
|
||
|
||
const GAME_PROJECT_DIRECTORIES = [
|
||
'assets/generated/meowa',
|
||
'assets/models',
|
||
'assets/review-previews',
|
||
'public/assets',
|
||
'public/models',
|
||
] as const;
|
||
|
||
const MARKDOWN_FENCE = '```';
|
||
|
||
function buildGameProjectDocuments(): Record<string, string> {
|
||
const header = (title: string, owner: string) => `# ${title}\n\nProject Version: v0.1.0\nDocument Revision: 1\nLast Updated By: ${owner}\n\n`;
|
||
return {
|
||
'GDD.md': `${header('游戏设计', 'game-design')}## 当前目标\n\n- 玩家目标:待确认\n- 核心循环:待确认\n- 首个可试玩结果:待确认\n- 渲染模式:2D(默认)或 3D(用户明确选择时)\n- 技术方案:待确认(2D 使用 Phaser 3.90.0;3D 使用 Three.js + TypeScript)\n- 当前版本边界:只推进一个可验证的玩家体验增量。\n\n## 已确认事实\n\n- 待补充。\n\n## 设计假设与风险\n\n- 假设:待补充。\n- 风险:待补充。\n\n## 交接\n\n- 给 game-art:玩家幻想、反馈目标、渲染模式下需要表达的视觉信息,待补充。\n- 给 game-development:规则、输入、状态变化、渲染模式和验收条件,待补充。\n- 给 game-test-release:核心循环、目标浏览器和真实试玩路径,待补充。\n`,
|
||
'TECH_STACK.md': `${header('游戏技术方案', 'game-development')}## 选择规则\n\n- 渲染模式:2D(默认)或 3D(用户明确选择时)。\n- 2D 引擎:Phaser 3.90.0,严格固定版本。\n- 3D 引擎:Three.js + TypeScript,必须记录精确的 three 版本和 lockfile。\n- 当前选择:2D / Phaser 3.90.0(待确认)。\n\n## 3D 路径边界\n\n- 用户明确选择 3D 后,先更新本文件、GDD.md 和 TASKS.md,再开始实现;不得把 3D 需求静默降级成 2D,也不得把已有 2D 项目静默迁移成 3D。\n- 3D 首个结果必须包含真实浏览器中的场景、相机、一个可操作对象、反馈和重开/继续路径。\n- 3D 模型优先使用 GLB/glTF,模型、纹理、动画和授权记录保存在项目内。\n- Meowa game-assets 当前只生成 2D 像素/高清图;3D 模型不走 Meowa 生成链路。\n\n## 浏览器与验收\n\n- 目标浏览器和桌面/移动视口:待确认。\n- 3D 需要记录相机、灯光、加载失败、resize、device-pixel-ratio 和性能证据。\n- 只使用项目包管理器和本地资源;不依赖 CDN-only runtime。\n`,
|
||
'ASSET_PLAN.md': `${header('游戏素材计划', 'game-art')}## 使用顺序\n\n1. 先搜索 Works Square。\n2. Works Square 不可用或没有合适结果时,再找许可证明确的开源来源。\n3. 2D 缺口仍明确时,使用项目内固定版 game-assets Skill,通过 Main 代理调用 Meowa。\n4. 3D 模型缺口使用用户提供、项目内已有或许可证明确的 GLB/glTF 等来源;不把 2D 生成图伪装成 3D 模型。\n\n## 2D/3D 资产边界\n\n- Meowa game-assets 当前只负责 2D 像素/高清光栅素材;2D 纹理、UI 和参考图仍需经过相同的预览、授权和审核流程。\n- 3D 模型、骨骼动画、材质和环境资源记录在 assets/models/ 或 public/models/,优先使用 GLB/glTF,并记录来源、作者、许可证、格式、版本和预览。\n- 生成物只能写入 assets/generated/meowa/<run>/;3D 外部来源必须先复制或整理到项目内,再进入审核。\n\n## 生成规则\n\n- 开始前读取 GDD.md、TECH_STACK.md、ART_GUIDE.md、TASKS.md 和 .niancode/asset-review.json(如果存在)。\n- 生成前运行 skill-doc、config-status 和 template-info;确认凭据和真实模版可用后再提交生成。\n- 生成物只能写入 assets/generated/meowa/<run>/,并保留任务响应、最终响应、下载文件和 generation.meta.json。\n- 元数据必须记录 provider、Skill 版本、模板、提示词、任务 ID、生成时间、条款链接和授权状态。\n- 生成物先作为 candidate 保存预览;用户明确审核通过后,game-development 才能接入 public/assets/ 或 public/models/。\n- Meowa 生成物不能默认标成 CC0 或商业可用;授权不清、3D 模型来源不清或没有预览证据时停止并记录阻塞。\n\n## 机器可核验资产清单\n\n${MARKDOWN_FENCE}json\n{\n "schemaVersion": 1,\n "selectionStatus": "draft",\n "selectionDecision": "pending",\n "confirmedAssetIds": [],\n "unresolvedRequiredAssetIds": [],\n "assets": []\n}\n${MARKDOWN_FENCE}\n\n> fenced JSON 是唯一机器记录;Markdown 表格只用于给人阅读,不能替代资产清单。\n\n## 候选与接入记录\n\n| ID | 用途 | 类型 | 来源/Provider | 项目内路径 | 许可证/授权状态 | 预览证据 | 状态 |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| 待补充 | 待补充 | 2D/3D | 待补充 | 待补充 | 待核对 | 待补充 | draft |\n\n## 审核与交接\n\n- 审核批次:待生成。\n- 用户决定:待确认。\n- 接入说明:待补充。\n`,
|
||
'ART_GUIDE.md': `${header('游戏美术规范', 'game-art')}## 视觉目标\n\n- 风格:与 GDD.md 中已确认的玩家幻想和渲染模式一致,待补充。\n- 画面重点:让玩家一眼看懂角色、危险、目标和反馈。\n- 资产之间保持一致的比例、视角、轮廓、光照、色彩和命名。\n\n## 2D 规格\n\n- 类型:sprite、tileset、背景、UI、特效或音频,按 ASSET_PLAN.md 逐项确认。\n- 尺寸、透明背景、锚点、帧数、色板和导出格式:每次接入前明确记录。\n- 像素素材使用 nearest-neighbor;高清素材保留原始尺寸和可追溯预览。\n\n## 3D 规格\n\n- 模型格式优先 GLB/glTF;记录坐标系、单位、原点、朝向、骨骼/动画、材质、纹理、面数和文件大小。\n- 记录相机、灯光、阴影、透明度、碰撞体和加载失败时的占位方案。\n- 3D 模型不由 Meowa game-assets 生成;必须有项目内路径、预览、来源和授权状态。\n\n## 生成与验收\n\n- 先查已有项目素材和 Works Square,再处理明确缺口。\n- 使用 game-assets Skill 时保留 generation.meta.json,不在 Agent 中暴露 Key 或直接访问 Meowa。\n- 每个候选必须有项目内可预览证据、来源和授权状态;未审核候选不得替代正式资产。\n- 正式接入只使用审核状态为 approved 的项目内文件。\n`,
|
||
};
|
||
}
|
||
|
||
export type ProjectConfigReadResult =
|
||
| { status: 'valid'; config: ProjectConfig }
|
||
| { status: 'missing' }
|
||
| { status: 'invalid'; error: string };
|
||
|
||
function configPath(projectPath: string): string {
|
||
return path.join(projectPath, PROJECT_CONFIG_PATH);
|
||
}
|
||
|
||
function normalizeStringList(value: unknown): string[] {
|
||
if (!Array.isArray(value)) return [];
|
||
return [...new Set(value.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean))];
|
||
}
|
||
|
||
function normalizeAgent(value: unknown): ProjectAgentConfig | null {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||
const raw = value as Partial<ProjectAgentConfig>;
|
||
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
|
||
const name = typeof raw.name === 'string' ? raw.name.trim() : '';
|
||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) return null;
|
||
const responsibility = raw.responsibility;
|
||
if (!responsibility || typeof responsibility !== 'object') return null;
|
||
return {
|
||
id,
|
||
avatarId: typeof raw.avatarId === 'string' && /^avatar-(0[1-9]|1[0-6])$/.test(raw.avatarId) ? raw.avatarId : 'avatar-01',
|
||
roleName: typeof raw.roleName === 'string' && raw.roleName.trim() ? raw.roleName.trim() : '项目伙伴',
|
||
name,
|
||
builtIn: raw.builtIn === true,
|
||
enabled: true,
|
||
model: typeof raw.model === 'string' && raw.model.trim() ? raw.model.trim() : null,
|
||
skillIds: [...new Set([YOUTH_PLAIN_LANGUAGE_SKILL_ID, ...normalizeStringList(raw.skillIds)])],
|
||
responsibility: {
|
||
mission: typeof responsibility.mission === 'string' ? responsibility.mission.trim() : '',
|
||
owns: normalizeStringList(responsibility.owns),
|
||
boundaries: normalizeStringList(responsibility.boundaries),
|
||
collaborators: normalizeStringList(responsibility.collaborators),
|
||
principles: normalizeStringList(responsibility.principles),
|
||
},
|
||
prompt: typeof raw.prompt === 'string' && raw.prompt.trim()
|
||
? raw.prompt.trim()
|
||
: buildResponsibilityPrompt(
|
||
typeof raw.roleName === 'string' && raw.roleName.trim() ? raw.roleName.trim() : '项目伙伴',
|
||
{
|
||
mission: typeof responsibility.mission === 'string' ? responsibility.mission.trim() : '',
|
||
owns: normalizeStringList(responsibility.owns),
|
||
boundaries: normalizeStringList(responsibility.boundaries),
|
||
collaborators: normalizeStringList(responsibility.collaborators),
|
||
principles: normalizeStringList(responsibility.principles),
|
||
},
|
||
),
|
||
};
|
||
}
|
||
|
||
export function normalizeProjectConfig(value: unknown): ProjectConfig {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Project config must be an object');
|
||
const raw = value as Partial<ProjectConfig>;
|
||
if (raw.schemaVersion !== 1) throw new Error('Unsupported project config schema');
|
||
if (!isProjectTemplateId(raw.templateId)) throw new Error('Unknown project template');
|
||
const agents = Array.isArray(raw.agents) ? raw.agents.map(normalizeAgent) : [];
|
||
if (agents.some((item) => !item)) throw new Error('Invalid project Agent configuration');
|
||
const normalizedAgents = agents.filter((item): item is ProjectAgentConfig => Boolean(item));
|
||
const templateRoleNames = new Map(
|
||
projectTemplates.find((template) => template.id === raw.templateId)?.agents.map((agent) => [agent.id, agent.roleName]) ?? [],
|
||
);
|
||
for (const item of normalizedAgents) {
|
||
if (item.builtIn && templateRoleNames.has(item.id)) item.roleName = templateRoleNames.get(item.id)!;
|
||
}
|
||
if (new Set(normalizedAgents.map((item) => item.id)).size !== normalizedAgents.length) throw new Error('Duplicate project Agent id');
|
||
const createdAt = typeof raw.createdAt === 'string' && raw.createdAt ? raw.createdAt : new Date().toISOString();
|
||
const initialized = raw.initialized === true;
|
||
if (initialized && validateAgentNames(normalizedAgents).length > 0) throw new Error('Initialized project has invalid Agent names');
|
||
return {
|
||
schemaVersion: 1,
|
||
templateId: raw.templateId,
|
||
initialized,
|
||
superpowersEnabled: typeof raw.superpowersEnabled === 'boolean'
|
||
? raw.superpowersEnabled
|
||
: getDefaultSuperpowersEnabled(raw.templateId),
|
||
defaultModel: typeof raw.defaultModel === 'string' && raw.defaultModel.trim() ? raw.defaultModel.trim() : null,
|
||
agents: normalizedAgents,
|
||
knowledgeDirectory: 'knowledge',
|
||
createdAt,
|
||
updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt ? raw.updatedAt : createdAt,
|
||
};
|
||
}
|
||
|
||
export async function readProjectConfig(projectPath: string): Promise<ProjectConfigReadResult> {
|
||
try {
|
||
const raw = JSON.parse(await readFile(configPath(projectPath), 'utf8')) as unknown;
|
||
return { status: 'valid', config: normalizeProjectConfig(raw) };
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' };
|
||
return { status: 'invalid', error: error instanceof Error ? error.message : String(error) };
|
||
}
|
||
}
|
||
|
||
function yamlString(value: string): string {
|
||
return JSON.stringify(value);
|
||
}
|
||
|
||
export function buildProjectAgentPrompt(config: ProjectConfig, current: ProjectAgentConfig): string {
|
||
const peers = config.agents.filter((agent) => agent.id !== current.id);
|
||
const versionOwnerId = versionOwnerForTemplate(config.templateId);
|
||
const versionAuthority = current.id === versionOwnerId
|
||
? [
|
||
'## 版本负责人权限',
|
||
'- 你是当前项目的版本负责人。评估用户和其他 Agent 在 `TASKS.md` 中留下的升版建议,并在新版工作开始前创建版本范围。',
|
||
'- 初始版本是 v0.1.0。修复、小优化或素材替换使用 PATCH;新增用户/玩家可感知能力使用 MINOR;正式发布或不兼容核心变化使用 MAJOR。',
|
||
'- 关闭版本前确认所有任务为 Done、Deferred 或明确 Blocked;创建增量 `docs/versions/<version>/VERSION_SUMMARY.md`,只归档本版本变化的文档,把未完成任务带来源迁移到下一版本。',
|
||
'- 只有你或用户可以把 `VERSION.md` 状态改为 active、released、archived 或 cancelled。归档后只通过明确勘误修正历史。',
|
||
'',
|
||
]
|
||
: [];
|
||
const lines = [
|
||
`# ${current.name} · ${current.roleName}`,
|
||
'',
|
||
`你的名字是「${current.name}」,在与用户对话和介绍自己时使用这个名字。`,
|
||
`你在当前项目中的职能是「${current.roleName}」。`,
|
||
'',
|
||
`你的稳定角色 ID 是 \`${current.id}\`。你只属于当前项目。`,
|
||
'',
|
||
current.prompt || '按照用户要求完成当前项目内的职责。',
|
||
'面向学生、家长或老师输出内容前,使用 `youth-plain-language`,把结论、操作和必要技术词讲成 10 至 16 岁青少年能直接看懂的简体中文。',
|
||
...versionAuthority,
|
||
'- 这些边界是协作规则,不是文件系统权限。用户明确要求跨界时可以执行,但应说明影响。',
|
||
'',
|
||
'## 项目伙伴',
|
||
...peers.map((peer) => `- ${peer.name}(${peer.roleName},${peer.id})`),
|
||
'',
|
||
'遇到属于其他 Agent 的任务时,明确指出合适的伙伴、需要交接的文件和下一步,不要假装其他 Agent 不存在。',
|
||
'',
|
||
'- 所有项目 Agent 共享当前项目目录;项目文件是跨会话协作的权威上下文,不依赖自动派发。',
|
||
'- 开始前读取与任务有关的上游产物,结束前把实际进展、假设、证据、风险和下一步写入你负责的项目文件。聊天总结不能代替文件产物。',
|
||
'- 每次开始工作前读取 `VERSION.md` 和 `TASKS.md`。`VERSION.md` 是当前项目版本的唯一权威索引;`TASKS.md` 是所有 Agent 共同维护的当前版本任务中枢。',
|
||
'- 只更新与你工作有关的共享任务状态、Owner、验收、证据、阻塞、版本建议和文档链接;专业事实仍写入你的角色产物。',
|
||
'- 更新角色 Markdown 文档时同步 `Project Version`、`Document Revision`、`Last Updated By`。项目版本取自 `VERSION.md`,每次实质修改增加文档修订号。',
|
||
'- 如果你不是当前 `Version Owner`,只能在 `TASKS.md` 提议 PATCH、MINOR 或 MAJOR 版本及理由,不自行创建或关闭项目版本。用户直接指定版本时遵循用户决定。',
|
||
'- 根目录文档只保存当前有效内容;历史版本使用 `docs/versions/<version>/` 的增量快照和 `VERSION_SUMMARY.md`,归档内容原则上只读。',
|
||
'- 优先读取项目根目录 `knowledge/` 中与任务相关的资料。',
|
||
'- 尊重用户已有文件和未提交改动。',
|
||
'',
|
||
];
|
||
return lines.join('\n');
|
||
}
|
||
|
||
function buildAgentMarkdown(config: ProjectConfig, agent: ProjectAgentConfig): string {
|
||
const skills = agent.skillIds.length > 0
|
||
? agent.skillIds.map((skill) => ` ${skill}: allow`).join('\n')
|
||
: ' "*": deny';
|
||
const shellPermission = agent.skillIds.includes('game-assets') ? ' bash: allow\n' : '';
|
||
const model = agent.model ?? config.defaultModel;
|
||
return `---
|
||
description: ${yamlString(agent.name)}
|
||
mode: all
|
||
${model ? `model: ${yamlString(model)}\n` : ''}permission:
|
||
${shellPermission} # Skills that produce local assets need the bundled CLI, never a remote bootstrap.
|
||
skill:
|
||
${skills}
|
||
---
|
||
|
||
${buildProjectAgentPrompt(config, agent)}`;
|
||
}
|
||
|
||
async function materializeAgents(projectPath: string, config: ProjectConfig): Promise<void> {
|
||
const agentDirectory = path.join(projectPath, '.opencode', 'agent');
|
||
await mkdir(agentDirectory, { recursive: true });
|
||
await Promise.all(config.agents.map(async (agent) => {
|
||
await writeFile(path.join(agentDirectory, `${agent.id}.md`), buildAgentMarkdown(config, agent), 'utf8');
|
||
}));
|
||
}
|
||
|
||
export async function createInitialProjectConfig(
|
||
projectPath: string,
|
||
templateId: ProjectTemplateId,
|
||
options?: { defaultModel?: string | null },
|
||
): Promise<ProjectConfig> {
|
||
const config = createProjectConfig(templateId);
|
||
if (typeof options?.defaultModel === 'string' && options.defaultModel.trim()) {
|
||
config.defaultModel = options.defaultModel.trim();
|
||
}
|
||
await mkdir(path.join(projectPath, '.niancode'), { recursive: true });
|
||
await mkdir(path.join(projectPath, 'knowledge'), { recursive: true });
|
||
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
||
await writeFile(path.join(projectPath, 'VERSION.md'), initialVersionDocument(templateId, config.createdAt), { encoding: 'utf8', flag: 'wx' });
|
||
await writeFile(path.join(projectPath, 'TASKS.md'), initialTaskDocument(templateId), { encoding: 'utf8', flag: 'wx' });
|
||
if (templateId === 'game-development') {
|
||
await Promise.all(GAME_PROJECT_DIRECTORIES.map((directory) => mkdir(path.join(projectPath, directory), { recursive: true })));
|
||
await Promise.all(Object.entries(buildGameProjectDocuments()).map(([fileName, contents]) => (
|
||
writeFile(path.join(projectPath, fileName), contents, { encoding: 'utf8', flag: 'wx' })
|
||
)));
|
||
}
|
||
return config;
|
||
}
|
||
|
||
export async function writeProjectConfig(projectPath: string, value: unknown): Promise<ProjectConfig> {
|
||
const previous = await readProjectConfig(projectPath);
|
||
if (previous.status !== 'valid') throw new Error('Project configuration is missing or invalid');
|
||
const config = normalizeProjectConfig({
|
||
...(value as object),
|
||
schemaVersion: 1,
|
||
templateId: previous.config.templateId,
|
||
createdAt: previous.config.createdAt,
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
if (config.initialized) {
|
||
const nameErrors = validateAgentNames(config.agents);
|
||
if (nameErrors.length > 0) throw new Error(`Invalid Agent names: ${nameErrors.join(', ')}`);
|
||
await materializeAgents(projectPath, config);
|
||
}
|
||
await writeFile(configPath(projectPath), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
||
return config;
|
||
}
|
||
|
||
export async function listProjectKnowledge(projectPath: string): Promise<string[]> {
|
||
const directory = path.join(projectPath, 'knowledge');
|
||
await mkdir(directory, { recursive: true });
|
||
return (await readdir(directory, { withFileTypes: true }))
|
||
.filter((entry) => entry.isFile())
|
||
.map((entry) => entry.name)
|
||
.sort((left, right) => left.localeCompare(right));
|
||
}
|