cowork-flow 0.0.48 → 0.0.49
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/README.md +16 -1
- package/package.json +6 -3
- package/src/cli.js +6 -0
- package/src/commands/source-refresh.js +40 -0
- package/src/lib/copy-template.js +130 -1
- package/src/lib/host-assets.js +216 -71
- package/template/.codex/hooks.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/task.py +0 -2
- package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +12 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +18 -133
- package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +82 -1
- package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +98 -114
- package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
- package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
- package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
- package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
- package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
- package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
- package/template/.cowork-flow/scripts/services/lifecycle_checks.py +23 -4
- package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
- package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
- package/template/.cowork-flow/scripts/services/task_archive.py +100 -14
- package/template/.cowork-flow/scripts/services/task_context.py +49 -479
- package/template/.cowork-flow/scripts/services/task_creation.py +31 -27
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
- package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
- package/template/.cowork-flow/spec/runtime/index.md +1 -1
- package/template/.cowork-flow/spec/schemas/host-assets.schema.json +32 -6
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/skills/batch-execution/scripts/batch_execution.py +111 -629
- package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
- package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
- package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
- package/template/skills/cowork-flow/SKILL.md +3 -0
- package/template/skills/party-mode/scripts/party_actions.py +95 -0
- package/template/skills/party-mode/scripts/party_mode_v2.py +129 -205
- package/template/skills/party-mode/scripts/party_reports.py +177 -0
- package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
- package/template/skills/runtime-health/scripts/doctor.py +204 -15
- package/template/skills/task-review/scripts/review_check.py +135 -2
- package/template/skills/test-first/SKILL.md +18 -3
- package/template/skills/test-first/manifest.json +8 -4
package/README.md
CHANGED
|
@@ -46,6 +46,12 @@ cowork-flow sync ./my-project --dry-run
|
|
|
46
46
|
# 同步已初始化项目
|
|
47
47
|
cowork-flow sync ./my-project
|
|
48
48
|
|
|
49
|
+
# 维护者:预览本仓库 source checkout live runtime / Skill replica 刷新
|
|
50
|
+
npm run source:refresh:dry-run
|
|
51
|
+
|
|
52
|
+
# 维护者:刷新 ignored root .cowork-flow、.agents/skills、.claude/skills
|
|
53
|
+
npm run source:refresh
|
|
54
|
+
|
|
49
55
|
# 预览 CLI 更新(不安装)
|
|
50
56
|
cowork-flow update --dry-run
|
|
51
57
|
|
|
@@ -104,6 +110,7 @@ Skills 维护在 `template/skills/` 唯一源码,`init` / `sync` 时按目录
|
|
|
104
110
|
|---|---|
|
|
105
111
|
| `init <path>` | 初始化项目模板 |
|
|
106
112
|
| `sync <path> [--dry-run]` | 同步已初始化项目的模板和技能 |
|
|
113
|
+
| `source-refresh [path] [--dry-run]` | 维护者刷新 source checkout 的 ignored live runtime 与 Host Skill replica |
|
|
107
114
|
| `install-zcode-plugin` | 安装 ZCode 插件到全局缓存 |
|
|
108
115
|
| `update [--dry-run]` | 升级 CLI 本身 |
|
|
109
116
|
|
|
@@ -126,6 +133,13 @@ Skills 维护在 `template/skills/` 唯一源码,`init` / `sync` 时按目录
|
|
|
126
133
|
- **Dry-run readiness**:`sync --dry-run` 只构建计划并输出 `readiness=<json>`,不写文件或事务状态;字段包含 `wouldCopy`、`wouldSkipProtected`、`wouldRemoveObsolete`、`hostAssetRefresh`、`pendingRecovery`、`warnings`
|
|
127
134
|
- `--force` 整文件覆盖保护文件
|
|
128
135
|
|
|
136
|
+
### source-refresh 行为
|
|
137
|
+
|
|
138
|
+
- **用途**:仅面向 cowork-flow 源码 checkout 维护者;以 `template/.cowork-flow/` 和 `template/skills/` 为唯一 tracked 分发源,刷新 ignored 的根 `.cowork-flow/`、`.agents/skills/`、`.claude/skills/` 受管副本
|
|
139
|
+
- **保护边界**:不覆盖 `.cowork-flow/tasks/`、`.cowork-flow/plans/`、`.cowork-flow/.runtime/`、`.cowork-flow/.developer`、`.cowork-flow/config.yaml` 和自定义 Skill
|
|
140
|
+
- **事务语义**:复用 Asset Plan / plan applier,失败时回滚;`.cowork-flow/.version` 保持 version-last,并复制 template 版本文件的原始内容
|
|
141
|
+
- **常用命令**:`npm run source:refresh:dry-run` 只预览;`npm run source:refresh` 应用后再运行 `./.cowork-flow/run doctor --all --json`
|
|
142
|
+
|
|
129
143
|
### update 行为
|
|
130
144
|
|
|
131
145
|
- 默认查询 npm latest,发现新版本时执行 `npm install -g cowork-flow@latest`
|
|
@@ -251,6 +265,7 @@ npm run test:fast # 快速 Node 测试,等价于 npm test
|
|
|
251
265
|
npm run test:integration # init/sync 关键集成路径
|
|
252
266
|
npm run test:node:full # 完整 Node 测试
|
|
253
267
|
npm run test:template # 核心模板集成测试
|
|
268
|
+
npm run test:windows:core # Windows core 发布信心门禁(Node/Python/init/sync/pack/模板)
|
|
254
269
|
npm run test:template:full # 完整模板 Python discovery
|
|
255
270
|
npm run test:all # 发布前全量测试与打包检查
|
|
256
271
|
npm run release:check # 发布信心门禁;当前等价于 test:all
|
|
@@ -271,7 +286,7 @@ npm run release -- minor # minor
|
|
|
271
286
|
|
|
272
287
|
- 发布说明维护在 `CHANGELOG.md`;发布前更新当前版本段落,并保留 `release:check` 和 `git diff --check` 证据。
|
|
273
288
|
|
|
274
|
-
CI
|
|
289
|
+
CI 的 PR 同时运行 Ubuntu core 与 Windows core;发布工作流要求 Ubuntu 与 Windows full verification 均成功后才执行 publish。测试 job 不接触 `NPM_TOKEN`,仅 publish job 使用该 secret。
|
|
275
290
|
|
|
276
291
|
Windows 上发布前使用 `run.cmd` 入口验证;POSIX shell 专属 release 用例在没有 shell 的 Windows 环境会明确跳过,不得记录为通过。`release:check` 会保留这些 skip 报告,不把 skip 伪装成 pass。
|
|
277
292
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cowork-flow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.49",
|
|
4
4
|
"description": "CLI for installing and maintaining cowork-flow collaboration templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,15 +18,18 @@
|
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
20
|
"test": "npm run test:fast",
|
|
21
|
-
"test:fast": "node --test test/asset-plan.test.js test/cli.test.js test/host-assets.test.js test/opencode-plugin.test.js test/template-runner.test.js test/update.test.js test/zcode-plugin.test.js",
|
|
21
|
+
"test:fast": "node --test test/asset-plan.test.js test/cli.test.js test/host-assets.test.js test/opencode-plugin.test.js test/package.test.js test/template-runner.test.js test/update.test.js test/zcode-plugin.test.js",
|
|
22
22
|
"test:integration": "node --test --test-name-pattern=\"^(init copies the template into a new target directory|sync updates safe template files and preserves protected files)$\" test/init.test.js test/sync.test.js",
|
|
23
23
|
"test:node:full": "node --test",
|
|
24
24
|
"test:template": "node scripts/run-template-tests.js --suite core",
|
|
25
25
|
"test:template:full": "node scripts/run-template-tests.js --suite full",
|
|
26
26
|
"pack:check": "node scripts/pack-check.js",
|
|
27
|
+
"source:refresh:dry-run": "node bin/cowork-flow.js source-refresh --dry-run",
|
|
28
|
+
"source:refresh": "node bin/cowork-flow.js source-refresh",
|
|
27
29
|
"test:all": "npm run test:node:full && npm run test:template:full && npm run pack:check",
|
|
28
30
|
"release:check": "npm run test:all",
|
|
29
|
-
"release": "sh scripts/release.sh"
|
|
31
|
+
"release": "sh scripts/release.sh",
|
|
32
|
+
"test:windows:core": "npm run test:fast && npm run test:integration && npm run pack:check && npm run test:template"
|
|
30
33
|
},
|
|
31
34
|
"engines": {
|
|
32
35
|
"node": ">=20"
|
package/src/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createInterface } from 'node:readline/promises';
|
|
|
4
4
|
|
|
5
5
|
import { runInit } from './commands/init.js';
|
|
6
6
|
import { runInstallZCodePlugin } from './commands/install-zcode-plugin.js';
|
|
7
|
+
import { runSourceRefresh } from './commands/source-refresh.js';
|
|
7
8
|
import { runSync } from './commands/sync.js';
|
|
8
9
|
import { runUpdate } from './commands/update.js';
|
|
9
10
|
import { readPackageInfo } from './lib/package-info.js';
|
|
@@ -15,6 +16,7 @@ Usage:
|
|
|
15
16
|
cowork-flow install-zcode-plugin [--dry-run] [--force] [--prune-old]
|
|
16
17
|
cowork-flow update
|
|
17
18
|
cowork-flow sync [target] [--dry-run] [--force]
|
|
19
|
+
cowork-flow source-refresh [target] [--dry-run]
|
|
18
20
|
cowork-flow --version
|
|
19
21
|
cowork-flow --help
|
|
20
22
|
`;
|
|
@@ -173,6 +175,10 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|
|
173
175
|
return await runSync(args, { io });
|
|
174
176
|
}
|
|
175
177
|
|
|
178
|
+
if (command === 'source-refresh') {
|
|
179
|
+
return await runSourceRefresh(args, { io });
|
|
180
|
+
}
|
|
181
|
+
|
|
176
182
|
if (command === 'update') {
|
|
177
183
|
return await runUpdate(args, { io });
|
|
178
184
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applyPlan,
|
|
5
|
+
buildReadinessReport,
|
|
6
|
+
buildSourceCheckoutRefreshPlan,
|
|
7
|
+
formatReadinessReport,
|
|
8
|
+
summarizePlan
|
|
9
|
+
} from '../lib/copy-template.js';
|
|
10
|
+
|
|
11
|
+
function parseSourceRefreshArgs(args) {
|
|
12
|
+
const options = { dryRun: false, target: process.cwd() };
|
|
13
|
+
for (const arg of args) {
|
|
14
|
+
if (arg === '--dry-run') {
|
|
15
|
+
options.dryRun = true;
|
|
16
|
+
} else if (arg.startsWith('--')) {
|
|
17
|
+
throw new Error(`Unknown source-refresh option: ${arg}`);
|
|
18
|
+
} else {
|
|
19
|
+
options.target = resolve(arg);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return options;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function runSourceRefresh(args, { io, fileSystem } = {}) {
|
|
26
|
+
const options = parseSourceRefreshArgs(args);
|
|
27
|
+
const plan = await buildSourceCheckoutRefreshPlan(options.target);
|
|
28
|
+
|
|
29
|
+
await applyPlan(plan, { dryRun: options.dryRun, fileSystem });
|
|
30
|
+
io.writeOut(summarizePlan(plan, options.dryRun));
|
|
31
|
+
if (options.dryRun) {
|
|
32
|
+
const readiness = await buildReadinessReport(plan, { fileSystem });
|
|
33
|
+
io.writeOut(formatReadinessReport(readiness));
|
|
34
|
+
}
|
|
35
|
+
io.writeOut('Source checkout refresh: template/.cowork-flow -> .cowork-flow; template/skills -> host Skill replicas\n');
|
|
36
|
+
if (!options.dryRun) {
|
|
37
|
+
io.writeOut('Next check: ./.cowork-flow/run doctor --all --json\n');
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
package/src/lib/copy-template.js
CHANGED
|
@@ -24,10 +24,13 @@ async function listFiles(root, current = root) {
|
|
|
24
24
|
const files = [];
|
|
25
25
|
|
|
26
26
|
for (const entry of entries) {
|
|
27
|
+
if (entry.name === '__pycache__') {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
27
30
|
const absolute = join(current, entry.name);
|
|
28
31
|
if (entry.isDirectory()) {
|
|
29
32
|
files.push(...await listFiles(root, absolute));
|
|
30
|
-
} else if (entry.isFile()) {
|
|
33
|
+
} else if (entry.isFile() && !entry.name.endsWith('.pyc')) {
|
|
31
34
|
files.push(relative(root, absolute));
|
|
32
35
|
}
|
|
33
36
|
}
|
|
@@ -116,6 +119,67 @@ async function appendSkillFileActions(actions, {
|
|
|
116
119
|
}
|
|
117
120
|
}
|
|
118
121
|
|
|
122
|
+
async function appendSkillTargetActions(actions, {
|
|
123
|
+
targetDir,
|
|
124
|
+
skillTargets,
|
|
125
|
+
seen,
|
|
126
|
+
sync = false
|
|
127
|
+
}) {
|
|
128
|
+
const normalizedTargets = [...new Set(skillTargets.map(toTemplatePath))].sort();
|
|
129
|
+
for (const skill of await listSkillDirs()) {
|
|
130
|
+
const skillFiles = await listSkillFiles(skill);
|
|
131
|
+
for (const destBase of normalizedTargets) {
|
|
132
|
+
for (const skillFile of skillFiles) {
|
|
133
|
+
const destination = join(
|
|
134
|
+
targetDir,
|
|
135
|
+
destBase,
|
|
136
|
+
skill.id,
|
|
137
|
+
skillFile.skillRelativePath
|
|
138
|
+
);
|
|
139
|
+
if (seen.has(destination)) continue;
|
|
140
|
+
seen.add(destination);
|
|
141
|
+
const exists = await pathExists(destination);
|
|
142
|
+
const unchanged = sync && exists
|
|
143
|
+
? await sourceMatchesDestination(skillFile.source, destination)
|
|
144
|
+
: false;
|
|
145
|
+
actions.push({
|
|
146
|
+
action: exists ? (sync ? (unchanged ? 'skip' : 'update') : 'skip') : 'create',
|
|
147
|
+
source: skillFile.source,
|
|
148
|
+
destination,
|
|
149
|
+
relativePath: join(destBase, skill.id, skillFile.skillRelativePath)
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isSourceCheckoutProtected(relativePath) {
|
|
157
|
+
const normalized = toTemplatePath(relativePath);
|
|
158
|
+
if (hostRegistry.syncPolicy.protectedFiles.includes(normalized)) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
return hostRegistry.syncPolicy.protectedPrefixes
|
|
162
|
+
.filter((prefix) => prefix !== '.cowork-flow/spec/')
|
|
163
|
+
.some((prefix) => normalized.startsWith(prefix))
|
|
164
|
+
|| normalized.startsWith('.cowork-flow/.runtime/');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function shouldIncludeSourceCheckoutRuntimeFile(relativePath) {
|
|
168
|
+
const normalized = toTemplatePath(relativePath);
|
|
169
|
+
return normalized.startsWith('.cowork-flow/')
|
|
170
|
+
&& normalized !== '.cowork-flow/.version'
|
|
171
|
+
&& !isSourceCheckoutProtected(normalized);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isSourceRefreshObsoleteFile(relativePath) {
|
|
175
|
+
const normalized = toTemplatePath(relativePath);
|
|
176
|
+
if (isSourceCheckoutProtected(normalized)) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
return normalized.startsWith('.cowork-flow/')
|
|
180
|
+
|| hostRegistry.skillTargets.some((target) => normalized.startsWith(`${target}/`));
|
|
181
|
+
}
|
|
182
|
+
|
|
119
183
|
export async function buildInitPlan(targetDir, options = {}) {
|
|
120
184
|
const files = await listFiles(templateRoot);
|
|
121
185
|
const actions = [];
|
|
@@ -230,6 +294,71 @@ async function buildFileSyncAction({ source, destination, relativePath }) {
|
|
|
230
294
|
};
|
|
231
295
|
}
|
|
232
296
|
|
|
297
|
+
export async function buildSourceCheckoutRefreshPlan(targetDir, options = {}) {
|
|
298
|
+
const files = await listFiles(templateRoot);
|
|
299
|
+
const actions = [];
|
|
300
|
+
const seen = new Set();
|
|
301
|
+
|
|
302
|
+
for (const file of files) {
|
|
303
|
+
const templatePath = toTemplatePath(file);
|
|
304
|
+
if (!shouldIncludeSourceCheckoutRuntimeFile(templatePath)) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const source = join(templateRoot, file);
|
|
308
|
+
const destination = join(targetDir, file);
|
|
309
|
+
seen.add(destination);
|
|
310
|
+
const exists = await pathExists(destination);
|
|
311
|
+
if (!exists) {
|
|
312
|
+
actions.push({ action: 'create', source, destination, relativePath: file });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
actions.push(await buildFileSyncAction({
|
|
316
|
+
source,
|
|
317
|
+
destination,
|
|
318
|
+
relativePath: file
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
await appendSkillTargetActions(actions, {
|
|
323
|
+
targetDir,
|
|
324
|
+
skillTargets: options.skillTargets ?? hostRegistry.skillTargets,
|
|
325
|
+
seen,
|
|
326
|
+
sync: true
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const deletedObsoleteParents = [];
|
|
330
|
+
for (const file of hostRegistry.obsoleteSyncFiles()) {
|
|
331
|
+
if (!isSourceRefreshObsoleteFile(file) || isCoveredByDeletedParent(file, deletedObsoleteParents)) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const destination = join(targetDir, file);
|
|
335
|
+
if (await pathExists(destination)) {
|
|
336
|
+
deletedObsoleteParents.push(file);
|
|
337
|
+
actions.push({ action: 'delete', source: null, destination, relativePath: file });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const versionDestination = join(targetDir, '.cowork-flow', '.version');
|
|
342
|
+
const versionSource = join(templateRoot, '.cowork-flow', '.version');
|
|
343
|
+
const versionContent = await readFile(versionSource, 'utf8');
|
|
344
|
+
const versionExists = await pathExists(versionDestination);
|
|
345
|
+
const versionUnchanged = versionExists
|
|
346
|
+
&& (await readFile(versionDestination, 'utf8')) === versionContent;
|
|
347
|
+
actions.push({
|
|
348
|
+
action: versionExists ? (versionUnchanged ? 'skip' : 'update') : 'create',
|
|
349
|
+
source: null,
|
|
350
|
+
destination: versionDestination,
|
|
351
|
+
relativePath: '.cowork-flow/.version',
|
|
352
|
+
content: versionContent
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
return createAssetPlan({
|
|
356
|
+
kind: 'source-refresh',
|
|
357
|
+
targetDir,
|
|
358
|
+
actions
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
233
362
|
export async function buildSyncPlan(targetDir, options = {}) {
|
|
234
363
|
if (!await pathExists(join(targetDir, '.cowork-flow'))) {
|
|
235
364
|
throw new Error(`Target is not initialized: ${targetDir}`);
|
package/src/lib/host-assets.js
CHANGED
|
@@ -4,13 +4,14 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { templateRoot } from './paths.js';
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
export const hostAssetManifestPath =
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
export const hostAssetManifestPath = process.env.COWORK_FLOW_HOST_ASSET_MANIFEST
|
|
8
|
+
|| join(
|
|
9
|
+
templateRoot,
|
|
10
|
+
'.cowork-flow',
|
|
11
|
+
'spec',
|
|
12
|
+
'runtime',
|
|
13
|
+
'host-assets.json'
|
|
14
|
+
);
|
|
14
15
|
|
|
15
16
|
const REQUIRED_HOST_NEUTRAL_CAPABILITIES = [
|
|
16
17
|
'task_action',
|
|
@@ -24,6 +25,45 @@ const REQUIRED_CAPABILITY_MATRIX_HOSTS = [
|
|
|
24
25
|
'opencode',
|
|
25
26
|
'zcode'
|
|
26
27
|
];
|
|
28
|
+
const CAPABILITY_STATUS_VALUES = [
|
|
29
|
+
'native',
|
|
30
|
+
'shim',
|
|
31
|
+
'plugin',
|
|
32
|
+
'external',
|
|
33
|
+
'experimental',
|
|
34
|
+
'unsupported'
|
|
35
|
+
];
|
|
36
|
+
const CAPABILITY_DECLARATION_KEYS = ['status', 'fallback'];
|
|
37
|
+
const COMMAND_TARGET_KEYS = ['config', 'format', 'target'];
|
|
38
|
+
const COMMAND_TARGET_FORMATS = ['json', 'toml', 'yaml'];
|
|
39
|
+
const MANIFEST_KEYS = [
|
|
40
|
+
'schemaVersion',
|
|
41
|
+
'capabilityValues',
|
|
42
|
+
'capabilityMatrix',
|
|
43
|
+
'platforms',
|
|
44
|
+
'excludedPrefixes',
|
|
45
|
+
'syncPolicy'
|
|
46
|
+
];
|
|
47
|
+
const PLATFORM_KEYS = [
|
|
48
|
+
'id',
|
|
49
|
+
'displayName',
|
|
50
|
+
'aliases',
|
|
51
|
+
'detectAny',
|
|
52
|
+
'assetPrefixes',
|
|
53
|
+
'assetFiles',
|
|
54
|
+
'skillTarget',
|
|
55
|
+
'adapterPath',
|
|
56
|
+
'capabilities',
|
|
57
|
+
'commandTargets'
|
|
58
|
+
];
|
|
59
|
+
const SYNC_POLICY_KEYS = [
|
|
60
|
+
'protectedFiles',
|
|
61
|
+
'protectedPrefixes',
|
|
62
|
+
'safeFiles',
|
|
63
|
+
'safePrefixes',
|
|
64
|
+
'managedBlockFiles',
|
|
65
|
+
'obsoleteFiles'
|
|
66
|
+
];
|
|
27
67
|
|
|
28
68
|
|
|
29
69
|
export function loadHostAssetManifest(path = hostAssetManifestPath) {
|
|
@@ -229,74 +269,135 @@ function validateManifest(manifest) {
|
|
|
229
269
|
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
230
270
|
throw new Error('Host Asset Manifest must be an object');
|
|
231
271
|
}
|
|
272
|
+
assertKnownKeys(manifest, MANIFEST_KEYS, 'Host Asset Manifest');
|
|
232
273
|
if (manifest.schemaVersion !== 1) {
|
|
233
274
|
throw new Error(
|
|
234
275
|
`Unsupported Host Asset Manifest schemaVersion: ${manifest.schemaVersion}`
|
|
235
276
|
);
|
|
236
277
|
}
|
|
278
|
+
const allowed = validateCapabilityValues(manifest.capabilityValues);
|
|
237
279
|
if (!Array.isArray(manifest.platforms) || manifest.platforms.length === 0) {
|
|
238
280
|
throw new Error(
|
|
239
281
|
'Host Asset Manifest platforms must be a non-empty array'
|
|
240
282
|
);
|
|
241
283
|
}
|
|
242
|
-
|
|
284
|
+
const platformIds = [];
|
|
285
|
+
const seenPlatformIds = new Set();
|
|
286
|
+
const aliases = new Map();
|
|
287
|
+
for (const platform of manifest.platforms) {
|
|
288
|
+
validatePlatform(platform, allowed, seenPlatformIds, aliases);
|
|
289
|
+
platformIds.push(platform.id);
|
|
290
|
+
}
|
|
291
|
+
validateStringArray(
|
|
292
|
+
manifest.excludedPrefixes,
|
|
293
|
+
'Host Asset Manifest excludedPrefixes'
|
|
294
|
+
);
|
|
295
|
+
validateCapabilityMatrix(manifest.capabilityMatrix, allowed, platformIds);
|
|
296
|
+
validateSyncPolicy(manifest.syncPolicy);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function validateCapabilityValues(values) {
|
|
300
|
+
validateStringArray(
|
|
301
|
+
values,
|
|
302
|
+
'Host Asset Manifest capabilityValues',
|
|
303
|
+
{ unique: true }
|
|
304
|
+
);
|
|
305
|
+
if (!arraysEqual(values, CAPABILITY_STATUS_VALUES)) {
|
|
243
306
|
throw new Error(
|
|
244
|
-
|
|
307
|
+
`Host Asset Manifest capabilityValues must be ${CAPABILITY_STATUS_VALUES.join(', ')}`
|
|
245
308
|
);
|
|
246
309
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
310
|
+
return new Set(CAPABILITY_STATUS_VALUES);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function validatePlatform(platform, allowed, seenPlatformIds, aliases) {
|
|
314
|
+
if (!platform || typeof platform !== 'object' || Array.isArray(platform)) {
|
|
315
|
+
throw new Error('Every host platform must be an object');
|
|
250
316
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
`Host platform ${platform.id} ${key} must be an array`
|
|
265
|
-
);
|
|
266
|
-
}
|
|
317
|
+
assertKnownKeys(platform, PLATFORM_KEYS, `Host platform ${platform.id ?? '<unknown>'}`);
|
|
318
|
+
validateRequiredString(platform.id, 'Host platform id');
|
|
319
|
+
if (seenPlatformIds.has(platform.id)) {
|
|
320
|
+
throw new Error(`Duplicate host platform id: ${platform.id}`);
|
|
321
|
+
}
|
|
322
|
+
seenPlatformIds.add(platform.id);
|
|
323
|
+
validateRequiredString(platform.displayName, `Host platform ${platform.id} displayName`);
|
|
324
|
+
validateStringArray(platform.aliases, `Host platform ${platform.id} aliases`);
|
|
325
|
+
for (const alias of platform.aliases) {
|
|
326
|
+
const normalized = alias.toLowerCase();
|
|
327
|
+
const owner = aliases.get(normalized);
|
|
328
|
+
if (owner !== undefined) {
|
|
329
|
+
throw new Error(`Duplicate platform alias: ${alias}`);
|
|
267
330
|
}
|
|
331
|
+
aliases.set(normalized, platform.id);
|
|
268
332
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
333
|
+
validateStringArray(platform.detectAny, `Host platform ${platform.id} detectAny`);
|
|
334
|
+
validateStringArray(platform.assetPrefixes, `Host platform ${platform.id} assetPrefixes`);
|
|
335
|
+
validateStringArray(platform.assetFiles, `Host platform ${platform.id} assetFiles`);
|
|
336
|
+
if (platform.skillTarget !== null && typeof platform.skillTarget !== 'string') {
|
|
337
|
+
throw new Error(`Host platform ${platform.id} skillTarget must be a string or null`);
|
|
338
|
+
}
|
|
339
|
+
if (typeof platform.skillTarget === 'string' && platform.skillTarget.length === 0) {
|
|
340
|
+
throw new Error(`Host platform ${platform.id} skillTarget must be a non-empty string or null`);
|
|
341
|
+
}
|
|
342
|
+
validateRequiredString(platform.adapterPath, `Host platform ${platform.id} adapterPath`);
|
|
343
|
+
validatePlatformCapabilities(platform, allowed);
|
|
344
|
+
if (!Array.isArray(platform.commandTargets)) {
|
|
345
|
+
throw new Error(`Host platform ${platform.id} commandTargets must be an array`);
|
|
346
|
+
}
|
|
347
|
+
for (const target of platform.commandTargets) {
|
|
348
|
+
validateCommandTarget(target, platform.id);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function validatePlatformCapabilities(platform, allowed) {
|
|
353
|
+
if (!platform.capabilities || typeof platform.capabilities !== 'object' || Array.isArray(platform.capabilities)) {
|
|
354
|
+
throw new Error(`Host platform ${platform.id} capabilities must be an object`);
|
|
355
|
+
}
|
|
356
|
+
if (Object.keys(platform.capabilities).length === 0) {
|
|
357
|
+
throw new Error(`Host platform ${platform.id} capabilities must be a non-empty object`);
|
|
358
|
+
}
|
|
359
|
+
for (const [name, value] of Object.entries(platform.capabilities)) {
|
|
360
|
+
validateRequiredString(name, `Host platform ${platform.id} capability name`);
|
|
361
|
+
if (typeof value !== 'string' || !allowed.has(value)) {
|
|
362
|
+
throw new Error(`Host platform ${platform.id} illegal capability status ${name}=${value}`);
|
|
281
363
|
}
|
|
282
364
|
}
|
|
283
365
|
}
|
|
284
366
|
|
|
367
|
+
function validateCommandTarget(target, platformId) {
|
|
368
|
+
if (!target || typeof target !== 'object' || Array.isArray(target)) {
|
|
369
|
+
throw new Error(`Host platform ${platformId} commandTargets must be objects`);
|
|
370
|
+
}
|
|
371
|
+
assertKnownKeys(target, COMMAND_TARGET_KEYS, `Host platform ${platformId} commandTarget`);
|
|
372
|
+
validateRequiredString(target.config, `Host platform ${platformId} commandTarget.config`);
|
|
373
|
+
validateRequiredString(target.target, `Host platform ${platformId} commandTarget.target`);
|
|
374
|
+
if (!COMMAND_TARGET_FORMATS.includes(target.format)) {
|
|
375
|
+
throw new Error(`Host platform ${platformId} commandTarget.format must be json, toml, or yaml`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
285
378
|
|
|
286
|
-
function
|
|
287
|
-
if (!
|
|
288
|
-
throw new Error('Host Asset Manifest
|
|
379
|
+
function validateSyncPolicy(syncPolicy) {
|
|
380
|
+
if (!syncPolicy || typeof syncPolicy !== 'object' || Array.isArray(syncPolicy)) {
|
|
381
|
+
throw new Error('Host Asset Manifest syncPolicy must be an object');
|
|
289
382
|
}
|
|
290
|
-
|
|
291
|
-
for (const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
383
|
+
assertKnownKeys(syncPolicy, SYNC_POLICY_KEYS, 'Host Asset Manifest syncPolicy');
|
|
384
|
+
for (const key of SYNC_POLICY_KEYS) {
|
|
385
|
+
validateStringArray(
|
|
386
|
+
syncPolicy[key],
|
|
387
|
+
`Host Asset Manifest syncPolicy.${key}`
|
|
388
|
+
);
|
|
295
389
|
}
|
|
296
|
-
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function validateCapabilityMatrix(matrix, allowed, platformIds) {
|
|
297
393
|
if (!matrix || typeof matrix !== 'object' || Array.isArray(matrix)) {
|
|
298
394
|
throw new Error('Host Asset Manifest capabilityMatrix must be an object');
|
|
299
395
|
}
|
|
396
|
+
assertKnownKeys(matrix, ['required', 'hosts'], 'Host Asset Manifest capabilityMatrix');
|
|
397
|
+
validateStringArray(
|
|
398
|
+
matrix.required,
|
|
399
|
+
'Host Asset Manifest capabilityMatrix.required'
|
|
400
|
+
);
|
|
300
401
|
if (!arraysEqual(matrix.required, REQUIRED_HOST_NEUTRAL_CAPABILITIES)) {
|
|
301
402
|
throw new Error(
|
|
302
403
|
`Host Asset Manifest capabilityMatrix.required must be ${REQUIRED_HOST_NEUTRAL_CAPABILITIES.join(', ')}`
|
|
@@ -307,7 +408,7 @@ function validateCapabilityMatrix(manifest) {
|
|
|
307
408
|
}
|
|
308
409
|
const requiredHosts = new Set([
|
|
309
410
|
...REQUIRED_CAPABILITY_MATRIX_HOSTS,
|
|
310
|
-
...
|
|
411
|
+
...platformIds
|
|
311
412
|
]);
|
|
312
413
|
for (const hostId of [...requiredHosts].sort()) {
|
|
313
414
|
if (!matrix.hosts[hostId]) {
|
|
@@ -315,6 +416,7 @@ function validateCapabilityMatrix(manifest) {
|
|
|
315
416
|
}
|
|
316
417
|
}
|
|
317
418
|
for (const [hostId, capabilities] of Object.entries(matrix.hosts)) {
|
|
419
|
+
validateRequiredString(hostId, 'Host Asset Manifest capabilityMatrix host id');
|
|
318
420
|
if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities)) {
|
|
319
421
|
throw new Error(`Host Asset Manifest capabilityMatrix host ${hostId} must be an object`);
|
|
320
422
|
}
|
|
@@ -324,31 +426,74 @@ function validateCapabilityMatrix(manifest) {
|
|
|
324
426
|
}
|
|
325
427
|
}
|
|
326
428
|
for (const capability of REQUIRED_HOST_NEUTRAL_CAPABILITIES) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
429
|
+
validateCapabilityDeclaration(
|
|
430
|
+
capabilities[capability],
|
|
431
|
+
allowed,
|
|
432
|
+
hostId,
|
|
433
|
+
capability
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function validateCapabilityDeclaration(declaration, allowed, hostId, capability) {
|
|
440
|
+
if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)) {
|
|
441
|
+
throw new Error(`Host Asset Manifest missing host-neutral capability ${hostId}:${capability}`);
|
|
442
|
+
}
|
|
443
|
+
assertKnownKeys(
|
|
444
|
+
declaration,
|
|
445
|
+
CAPABILITY_DECLARATION_KEYS,
|
|
446
|
+
`Host Asset Manifest capabilityMatrix.hosts.${hostId}.${capability}`
|
|
447
|
+
);
|
|
448
|
+
if (!allowed.has(declaration.status)) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`Host Asset Manifest illegal host-neutral capability ${hostId}:${capability}=${declaration.status}`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (declaration.fallback !== undefined && (
|
|
454
|
+
typeof declaration.fallback !== 'string' || declaration.fallback.length === 0
|
|
455
|
+
)) {
|
|
456
|
+
throw new Error(
|
|
457
|
+
`Host Asset Manifest capability fallback must be a non-empty string: ${hostId}:${capability}`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
if (declaration.status === 'unsupported' && !declaration.fallback) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
`Host Asset Manifest unsupported capability requires fallback: ${hostId}:${capability}`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function validateRequiredString(value, label) {
|
|
468
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
469
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function validateStringArray(value, label, { unique = false } = {}) {
|
|
474
|
+
if (!Array.isArray(value)) {
|
|
475
|
+
throw new Error(`${label} must be an array`);
|
|
476
|
+
}
|
|
477
|
+
const seen = new Set();
|
|
478
|
+
for (const item of value) {
|
|
479
|
+
if (typeof item !== 'string' || item.length === 0) {
|
|
480
|
+
throw new Error(`${label} entries must be non-empty strings`);
|
|
348
481
|
}
|
|
482
|
+
if (unique && seen.has(item)) {
|
|
483
|
+
throw new Error(`${label} entries must be unique: ${item}`);
|
|
484
|
+
}
|
|
485
|
+
seen.add(item);
|
|
349
486
|
}
|
|
350
487
|
}
|
|
351
488
|
|
|
489
|
+
function assertKnownKeys(value, allowedKeys, label) {
|
|
490
|
+
const allowed = new Set(allowedKeys);
|
|
491
|
+
for (const key of Object.keys(value)) {
|
|
492
|
+
if (!allowed.has(key)) {
|
|
493
|
+
throw new Error(`${label} unknown field: ${key}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
352
497
|
|
|
353
498
|
function normalizeCapabilityMatrix(matrix) {
|
|
354
499
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
0.0.
|
|
1
|
+
0.0.49
|