@clipbus/plugin-sdk 0.7.0 → 0.8.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/README.md +47 -0
- package/bin/validate-manifest.mjs +47 -0
- package/dist/validate/index.cjs +324 -0
- package/dist/validate/index.d.cts +49 -0
- package/dist/validate/index.d.ts +49 -0
- package/dist/validate/index.js +288 -0
- package/docs/manifest.md +2 -0
- package/package.json +15 -1
package/README.md
CHANGED
|
@@ -460,6 +460,53 @@ import type {
|
|
|
460
460
|
|
|
461
461
|
For the exhaustive type list, run `cd protocol/plugin && npm run codegen` and read the synced `data.generated.ts` next to this file.
|
|
462
462
|
|
|
463
|
+
## Manifest validation (`@clipbus/plugin-sdk/validate`)
|
|
464
|
+
|
|
465
|
+
The `./validate` entry point provides a manifest validator that stays aligned with the
|
|
466
|
+
host's manifest validation rules. Use it in CI or `npm run verify` to catch errors at
|
|
467
|
+
development time — the same errors the host reports at install time.
|
|
468
|
+
|
|
469
|
+
```js
|
|
470
|
+
const { validateManifest, MANIFEST_VALIDATION_CODES } = require('@clipbus/plugin-sdk/validate');
|
|
471
|
+
|
|
472
|
+
const manifest = JSON.parse(require('fs').readFileSync('manifest.json', 'utf8'));
|
|
473
|
+
const issues = validateManifest(manifest, { phase: 'manifestOnly' });
|
|
474
|
+
// issues: Array<{ code: string; message: string }>
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### `validateManifest(manifest, options?)`
|
|
478
|
+
|
|
479
|
+
| Parameter | Type | Description |
|
|
480
|
+
|---|---|---|
|
|
481
|
+
| `manifest` | `unknown` | Parsed `manifest.json` content (`JSON.parse` output). |
|
|
482
|
+
| `options.phase` | `'manifestOnly' \| 'runtimeLoadable'` | `'manifestOnly'` (default): pure JSON rules only. `'runtimeLoadable'`: also checks file existence and path containment; requires `rootDir`. |
|
|
483
|
+
| `options.rootDir` | `string` | Absolute path to the plugin root. Required for `'runtimeLoadable'`. |
|
|
484
|
+
|
|
485
|
+
Returns `ManifestValidationIssue[]`. An empty array means the manifest is valid.
|
|
486
|
+
|
|
487
|
+
Unlike the host validator (which stops at the first error), this function collects
|
|
488
|
+
**all** issues in a single pass — better DX for development.
|
|
489
|
+
|
|
490
|
+
### `MANIFEST_VALIDATION_CODES`
|
|
491
|
+
|
|
492
|
+
`readonly string[]` — all 26 error code strings, each a verbatim camelCase name that
|
|
493
|
+
matches the corresponding host error. Use for exhaustive handling or parity checks.
|
|
494
|
+
|
|
495
|
+
### CLI: `clipbus-validate-manifest`
|
|
496
|
+
|
|
497
|
+
Installed automatically when `@clipbus/plugin-sdk` is a dependency:
|
|
498
|
+
|
|
499
|
+
```sh
|
|
500
|
+
npx clipbus-validate-manifest [pluginDir] [--runtime]
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
- `pluginDir` — path to the plugin directory containing `manifest.json` (default: cwd).
|
|
504
|
+
- `--runtime` — use `runtimeLoadable` phase (checks file existence in addition to JSON rules).
|
|
505
|
+
|
|
506
|
+
Exits 0 on success, 1 if any issues are found.
|
|
507
|
+
|
|
508
|
+
---
|
|
509
|
+
|
|
463
510
|
## See also
|
|
464
511
|
|
|
465
512
|
- [API.md](./API.md) — autoritative API reference, regenerated from `protocol/plugin/src/catalog.ts`
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* clipbus-validate-manifest — CLI wrapper for validateManifest.
|
|
4
|
+
*
|
|
5
|
+
* Usage: clipbus-validate-manifest [pluginDir] [--runtime]
|
|
6
|
+
*
|
|
7
|
+
* pluginDir Path to the plugin root directory containing manifest.json.
|
|
8
|
+
* Defaults to the current working directory.
|
|
9
|
+
* --runtime Use 'runtimeLoadable' phase (checks file existence + path
|
|
10
|
+
* containment in addition to JSON rules). Default is 'manifestOnly'.
|
|
11
|
+
*
|
|
12
|
+
* Exit code 0 = valid, 1 = issues found or error.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { validateManifest } from '../dist/validate/index.js';
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const flags = args.filter((a) => a.startsWith('--'));
|
|
21
|
+
const positional = args.filter((a) => !a.startsWith('--'));
|
|
22
|
+
|
|
23
|
+
const pluginDir = positional[0] ? path.resolve(positional[0]) : process.cwd();
|
|
24
|
+
const phase = flags.includes('--runtime') ? 'runtimeLoadable' : 'manifestOnly';
|
|
25
|
+
|
|
26
|
+
const manifestPath = path.join(pluginDir, 'manifest.json');
|
|
27
|
+
let manifest;
|
|
28
|
+
try {
|
|
29
|
+
const raw = readFileSync(manifestPath, 'utf8');
|
|
30
|
+
manifest = JSON.parse(raw);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
33
|
+
process.stderr.write(`clipbus-validate-manifest: cannot read ${manifestPath}: ${msg}\n`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const issues = validateManifest(manifest, { phase, rootDir: pluginDir });
|
|
38
|
+
|
|
39
|
+
if (issues.length === 0) {
|
|
40
|
+
process.stdout.write(`manifest OK (${phase})\n`);
|
|
41
|
+
process.exit(0);
|
|
42
|
+
} else {
|
|
43
|
+
for (const iss of issues) {
|
|
44
|
+
process.stdout.write(`${iss.code}: ${iss.message}\n`);
|
|
45
|
+
}
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/validate/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
MANIFEST_VALIDATION_CODES: () => MANIFEST_VALIDATION_CODES,
|
|
34
|
+
validateManifest: () => validateManifest
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(index_exports);
|
|
37
|
+
var import_node_fs = require("node:fs");
|
|
38
|
+
var import_node_path = __toESM(require("node:path"), 1);
|
|
39
|
+
var MANIFEST_VALIDATION_CODES = [
|
|
40
|
+
"unsupportedSchemaVersion",
|
|
41
|
+
"missingManifest",
|
|
42
|
+
"missingRuntimeEntry",
|
|
43
|
+
"missingUIRoot",
|
|
44
|
+
"legacyFieldNotSupported",
|
|
45
|
+
"duplicateAttachmentRendererID",
|
|
46
|
+
"duplicateDetectorID",
|
|
47
|
+
"duplicateActionID",
|
|
48
|
+
"emptyPluginID",
|
|
49
|
+
"emptyPluginTitle",
|
|
50
|
+
"installEntryMissing",
|
|
51
|
+
"installEntryOutsideRoot",
|
|
52
|
+
"rendererTitleEmpty",
|
|
53
|
+
"detectorTitleEmpty",
|
|
54
|
+
"actionTitleEmpty",
|
|
55
|
+
"detectorMissingSupportedInputKinds",
|
|
56
|
+
"detectorAttachmentTypesEmpty",
|
|
57
|
+
"detectorAttachmentTypeNotRenderable",
|
|
58
|
+
"detectorAttachmentTypeOutsideNamespace",
|
|
59
|
+
"rendererUIEntryMissing",
|
|
60
|
+
"rendererUIEntryOutsideRoot",
|
|
61
|
+
"actionMissingSupportedItemTypes",
|
|
62
|
+
"actionUIEntryMissing",
|
|
63
|
+
"actionUIEntryOutsideRoot",
|
|
64
|
+
"detectorInvalidSupportedInputKind",
|
|
65
|
+
"actionInvalidSupportedItemType"
|
|
66
|
+
];
|
|
67
|
+
var CANONICAL_ITEM_TYPES = ["text", "image", "path_reference"];
|
|
68
|
+
function asRecord(val) {
|
|
69
|
+
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
|
|
70
|
+
return val;
|
|
71
|
+
}
|
|
72
|
+
return void 0;
|
|
73
|
+
}
|
|
74
|
+
function asString(val) {
|
|
75
|
+
return typeof val === "string" ? val : void 0;
|
|
76
|
+
}
|
|
77
|
+
function asStringArray(val) {
|
|
78
|
+
if (!Array.isArray(val)) return [];
|
|
79
|
+
return val.filter((v) => typeof v === "string");
|
|
80
|
+
}
|
|
81
|
+
function asRecordArray(val) {
|
|
82
|
+
if (!Array.isArray(val)) return [];
|
|
83
|
+
return val.filter((v) => asRecord(v) !== void 0);
|
|
84
|
+
}
|
|
85
|
+
function issue(code, message) {
|
|
86
|
+
return { code, message };
|
|
87
|
+
}
|
|
88
|
+
function isInsideRoot(entryRelative, rootAbsolute) {
|
|
89
|
+
const resolvedEntry = import_node_path.default.resolve(rootAbsolute, entryRelative);
|
|
90
|
+
return resolvedEntry === rootAbsolute || resolvedEntry.startsWith(rootAbsolute + import_node_path.default.sep);
|
|
91
|
+
}
|
|
92
|
+
function validateSchemaVersion(raw, issues) {
|
|
93
|
+
const version = typeof raw["schemaVersion"] === "number" ? raw["schemaVersion"] : 0;
|
|
94
|
+
if (version !== 2) {
|
|
95
|
+
issues.push(issue("unsupportedSchemaVersion", `Unsupported plugin schema version: ${version}`));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
var LEGACY_RENDERER_FIELDS = ["displayName", "defaultEnabled", "defaultOrderKey", "actions"];
|
|
99
|
+
var LEGACY_DETECTOR_FIELDS = ["displayName", "capabilityID", "defaultEnabled", "timeoutMs"];
|
|
100
|
+
var LEGACY_ACTION_FIELDS = ["displayName", "actionID", "defaultEnabled", "defaultOrderKey", "revealButtons"];
|
|
101
|
+
function validateLegacyFields(raw, issues) {
|
|
102
|
+
const plugin = asRecord(raw["plugin"]);
|
|
103
|
+
if (plugin && "displayName" in plugin) {
|
|
104
|
+
issues.push(issue("legacyFieldNotSupported", `Legacy manifest field is not supported in v2: plugin.displayName`));
|
|
105
|
+
}
|
|
106
|
+
const renderers = asRecordArray(raw["attachmentRenderers"]);
|
|
107
|
+
for (let i = 0; i < renderers.length; i++) {
|
|
108
|
+
for (const field of LEGACY_RENDERER_FIELDS) {
|
|
109
|
+
if (field in renderers[i]) {
|
|
110
|
+
issues.push(issue("legacyFieldNotSupported", `Legacy manifest field is not supported in v2: attachmentRenderers[${i}].${field}`));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const detectors = asRecordArray(raw["detectors"]);
|
|
115
|
+
for (let i = 0; i < detectors.length; i++) {
|
|
116
|
+
for (const field of LEGACY_DETECTOR_FIELDS) {
|
|
117
|
+
if (field in detectors[i]) {
|
|
118
|
+
issues.push(issue("legacyFieldNotSupported", `Legacy manifest field is not supported in v2: detectors[${i}].${field}`));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const actions = asRecordArray(raw["actions"]);
|
|
123
|
+
for (let i = 0; i < actions.length; i++) {
|
|
124
|
+
for (const field of LEGACY_ACTION_FIELDS) {
|
|
125
|
+
if (field in actions[i]) {
|
|
126
|
+
issues.push(issue("legacyFieldNotSupported", `Legacy manifest field is not supported in v2: actions[${i}].${field}`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function validateCanonicalItemTypeSpellings(raw, issues) {
|
|
132
|
+
for (const detector of asRecordArray(raw["detectors"])) {
|
|
133
|
+
const detectorID = asString(detector["id"]) ?? "";
|
|
134
|
+
for (const value of asStringArray(detector["supportedInputKinds"])) {
|
|
135
|
+
if (!CANONICAL_ITEM_TYPES.includes(value)) {
|
|
136
|
+
issues.push(issue("detectorInvalidSupportedInputKind", `Detector supported input kinds contain unsupported value: ${detectorID} -> ${value}`));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const action of asRecordArray(raw["actions"])) {
|
|
141
|
+
const actionID = asString(action["id"]) ?? "";
|
|
142
|
+
for (const value of asStringArray(action["supportedItemTypes"])) {
|
|
143
|
+
if (!CANONICAL_ITEM_TYPES.includes(value)) {
|
|
144
|
+
issues.push(issue("actionInvalidSupportedItemType", `Action supported item types contain unsupported value: ${actionID} -> ${value}`));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function validateDetectors(detectors, pluginID, renderableAttachmentTypes, issues) {
|
|
150
|
+
const seenIDs = /* @__PURE__ */ new Set();
|
|
151
|
+
const prefix = pluginID + ".";
|
|
152
|
+
for (const detector of detectors) {
|
|
153
|
+
const id = asString(detector["id"]) ?? "";
|
|
154
|
+
if (seenIDs.has(id)) {
|
|
155
|
+
issues.push(issue("duplicateDetectorID", `Duplicate detector id: ${id}`));
|
|
156
|
+
} else {
|
|
157
|
+
seenIDs.add(id);
|
|
158
|
+
}
|
|
159
|
+
const title = asString(detector["title"]) ?? "";
|
|
160
|
+
if (title.trim().length === 0) {
|
|
161
|
+
issues.push(issue("detectorTitleEmpty", `Detector title is empty: ${id}`));
|
|
162
|
+
}
|
|
163
|
+
const supportedInputKinds = asStringArray(detector["supportedInputKinds"]);
|
|
164
|
+
if (supportedInputKinds.length === 0) {
|
|
165
|
+
issues.push(issue("detectorMissingSupportedInputKinds", `Detector is missing supported input kinds: ${id}`));
|
|
166
|
+
}
|
|
167
|
+
const attachmentTypes = asStringArray(detector["attachmentTypes"]);
|
|
168
|
+
if (attachmentTypes.length === 0) {
|
|
169
|
+
issues.push(issue("detectorAttachmentTypesEmpty", `Detector attachment types are empty: ${id}`));
|
|
170
|
+
}
|
|
171
|
+
for (const attachmentType of attachmentTypes) {
|
|
172
|
+
if (!attachmentType.startsWith(prefix)) {
|
|
173
|
+
issues.push(issue("detectorAttachmentTypeOutsideNamespace", `Detector attachment type is outside plugin namespace: ${id} -> ${attachmentType}`));
|
|
174
|
+
} else if (!renderableAttachmentTypes.has(attachmentType)) {
|
|
175
|
+
issues.push(issue("detectorAttachmentTypeNotRenderable", `Detector attachment type is not renderable: ${id} -> ${attachmentType}`));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function validateActions(actions, issues) {
|
|
181
|
+
const seenIDs = /* @__PURE__ */ new Set();
|
|
182
|
+
for (const action of actions) {
|
|
183
|
+
const id = asString(action["id"]) ?? "";
|
|
184
|
+
if (seenIDs.has(id)) {
|
|
185
|
+
issues.push(issue("duplicateActionID", `Duplicate action id: ${id}`));
|
|
186
|
+
} else {
|
|
187
|
+
seenIDs.add(id);
|
|
188
|
+
}
|
|
189
|
+
const title = asString(action["title"]) ?? "";
|
|
190
|
+
if (title.trim().length === 0) {
|
|
191
|
+
issues.push(issue("actionTitleEmpty", `Action title is empty: ${id}`));
|
|
192
|
+
}
|
|
193
|
+
const supportedItemTypes = asStringArray(action["supportedItemTypes"]);
|
|
194
|
+
if (supportedItemTypes.length === 0) {
|
|
195
|
+
issues.push(issue("actionMissingSupportedItemTypes", `Action is missing supported item types: ${id}`));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function validateRootEntry(rawEntry, rootDir, issues, missingCode, missingMessage, outsideCode, outsideMessage) {
|
|
200
|
+
const trimmed = rawEntry.trim();
|
|
201
|
+
if (trimmed.length === 0) {
|
|
202
|
+
issues.push(issue(missingCode, missingMessage(rawEntry)));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!isInsideRoot(trimmed, rootDir)) {
|
|
206
|
+
issues.push(issue(outsideCode, outsideMessage(trimmed)));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!(0, import_node_fs.existsSync)(import_node_path.default.resolve(rootDir, trimmed))) {
|
|
210
|
+
issues.push(issue(missingCode, missingMessage(trimmed)));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function validateUIEntry(uiEntry, uiRootDir, issues, missingCode, missingMessage, outsideCode, outsideMessage) {
|
|
214
|
+
if (!uiEntry || uiEntry.trim().length === 0) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const trimmed = uiEntry.trim();
|
|
218
|
+
if (!isInsideRoot(trimmed, uiRootDir)) {
|
|
219
|
+
issues.push(issue(outsideCode, outsideMessage(trimmed)));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (!(0, import_node_fs.existsSync)(import_node_path.default.resolve(uiRootDir, trimmed))) {
|
|
223
|
+
issues.push(issue(missingCode, missingMessage(trimmed)));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function validateManifest(manifest, options = {}) {
|
|
227
|
+
const issues = [];
|
|
228
|
+
const phase = options.phase ?? "manifestOnly";
|
|
229
|
+
const rootDir = import_node_path.default.resolve(options.rootDir ?? process.cwd());
|
|
230
|
+
const raw = asRecord(manifest);
|
|
231
|
+
if (!raw) {
|
|
232
|
+
issues.push(issue("missingManifest", "Missing manifest.json"));
|
|
233
|
+
return issues;
|
|
234
|
+
}
|
|
235
|
+
validateSchemaVersion(raw, issues);
|
|
236
|
+
validateLegacyFields(raw, issues);
|
|
237
|
+
validateCanonicalItemTypeSpellings(raw, issues);
|
|
238
|
+
const plugin = asRecord(raw["plugin"]) ?? {};
|
|
239
|
+
const pluginID = asString(plugin["id"]) ?? "";
|
|
240
|
+
const pluginTitle = asString(plugin["title"]) ?? "";
|
|
241
|
+
if (pluginID.trim().length === 0) {
|
|
242
|
+
issues.push(issue("emptyPluginID", "Plugin id is empty"));
|
|
243
|
+
}
|
|
244
|
+
if (pluginTitle.trim().length === 0) {
|
|
245
|
+
issues.push(issue("emptyPluginTitle", "Plugin title is empty"));
|
|
246
|
+
}
|
|
247
|
+
const renderers = asRecordArray(raw["attachmentRenderers"]);
|
|
248
|
+
const renderableAttachmentTypes = /* @__PURE__ */ new Set();
|
|
249
|
+
const seenRendererIDs = /* @__PURE__ */ new Set();
|
|
250
|
+
for (const renderer of renderers) {
|
|
251
|
+
const id = asString(renderer["id"]) ?? "";
|
|
252
|
+
const attachmentType = asString(renderer["attachmentType"]) ?? "";
|
|
253
|
+
if (attachmentType) renderableAttachmentTypes.add(attachmentType);
|
|
254
|
+
if (seenRendererIDs.has(id)) {
|
|
255
|
+
issues.push(issue("duplicateAttachmentRendererID", `Duplicate attachment renderer id: ${id}`));
|
|
256
|
+
} else {
|
|
257
|
+
seenRendererIDs.add(id);
|
|
258
|
+
}
|
|
259
|
+
const title = asString(renderer["title"]) ?? "";
|
|
260
|
+
if (title.trim().length === 0) {
|
|
261
|
+
issues.push(issue("rendererTitleEmpty", `Attachment renderer title is empty: ${id}`));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
validateDetectors(asRecordArray(raw["detectors"]), pluginID, renderableAttachmentTypes, issues);
|
|
265
|
+
validateActions(asRecordArray(raw["actions"]), issues);
|
|
266
|
+
if (phase !== "runtimeLoadable") {
|
|
267
|
+
return issues;
|
|
268
|
+
}
|
|
269
|
+
const install = asRecord(raw["install"]);
|
|
270
|
+
if (install) {
|
|
271
|
+
const rawEntry = install["entry"] !== void 0 ? asString(install["entry"]) ?? "" : "";
|
|
272
|
+
validateRootEntry(
|
|
273
|
+
rawEntry.trim(),
|
|
274
|
+
rootDir,
|
|
275
|
+
issues,
|
|
276
|
+
"installEntryMissing",
|
|
277
|
+
(p) => `Missing install hook entry: ${p}`,
|
|
278
|
+
"installEntryOutsideRoot",
|
|
279
|
+
(p) => `Install hook entry is outside plugin root: ${p}`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const runtime = asRecord(raw["runtime"]) ?? {};
|
|
283
|
+
const nodeEntry = asString(runtime["nodeEntry"]) ?? "";
|
|
284
|
+
const uiRoot = asString(runtime["uiRoot"]) ?? "";
|
|
285
|
+
if (!(0, import_node_fs.existsSync)(import_node_path.default.resolve(rootDir, nodeEntry))) {
|
|
286
|
+
issues.push(issue("missingRuntimeEntry", `Missing runtime entry: ${nodeEntry}`));
|
|
287
|
+
}
|
|
288
|
+
const uiRootAbs = import_node_path.default.resolve(rootDir, uiRoot);
|
|
289
|
+
const declaresUISurface = renderers.length > 0 || asRecordArray(raw["actions"]).some((a) => {
|
|
290
|
+
const entry = asString(a["uiEntry"])?.trim();
|
|
291
|
+
return entry !== void 0 && entry.length > 0;
|
|
292
|
+
});
|
|
293
|
+
if (declaresUISurface && !(0, import_node_fs.existsSync)(uiRootAbs)) {
|
|
294
|
+
issues.push(issue("missingUIRoot", `Missing UI root: ${uiRoot}`));
|
|
295
|
+
}
|
|
296
|
+
for (const renderer of renderers) {
|
|
297
|
+
validateUIEntry(
|
|
298
|
+
asString(renderer["uiEntry"]),
|
|
299
|
+
uiRootAbs,
|
|
300
|
+
issues,
|
|
301
|
+
"rendererUIEntryMissing",
|
|
302
|
+
(p) => `Missing renderer UI entry: ${p}`,
|
|
303
|
+
"rendererUIEntryOutsideRoot",
|
|
304
|
+
(p) => `Renderer UI entry is outside uiRoot: ${p}`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
for (const action of asRecordArray(raw["actions"])) {
|
|
308
|
+
validateUIEntry(
|
|
309
|
+
asString(action["uiEntry"]),
|
|
310
|
+
uiRootAbs,
|
|
311
|
+
issues,
|
|
312
|
+
"actionUIEntryMissing",
|
|
313
|
+
(p) => `Missing action UI entry: ${p}`,
|
|
314
|
+
"actionUIEntryOutsideRoot",
|
|
315
|
+
(p) => `Action UI entry is outside uiRoot: ${p}`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return issues;
|
|
319
|
+
}
|
|
320
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
321
|
+
0 && (module.exports = {
|
|
322
|
+
MANIFEST_VALIDATION_CODES,
|
|
323
|
+
validateManifest
|
|
324
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @clipbus/plugin-sdk — manifest validation
|
|
3
|
+
*
|
|
4
|
+
* JavaScript mirror of the native PluginManifestValidator (Swift).
|
|
5
|
+
* Authority: platform/macos/Sources/Services/Impl/Plugins/PluginManifestValidator.swift
|
|
6
|
+
*
|
|
7
|
+
* Parity: scripts/checks/check-plugin-manifest-validator-parity.sh enforces that
|
|
8
|
+
* MANIFEST_VALIDATION_CODES equals the Swift ValidationError case set, and that
|
|
9
|
+
* CANONICAL_ITEM_TYPES equals ClipboardItemType.swift enum case raw values.
|
|
10
|
+
*
|
|
11
|
+
* Design difference from native: native throws on the first error (fail-fast); this
|
|
12
|
+
* function collects ALL issues and returns them — better DX for development so that
|
|
13
|
+
* authors see the full picture in one pass.
|
|
14
|
+
*/
|
|
15
|
+
/** A single manifest validation issue, analogous to Swift ValidationError. */
|
|
16
|
+
export interface ManifestValidationIssue {
|
|
17
|
+
/** Error code — verbatim Swift ValidationError case name (camelCase). */
|
|
18
|
+
code: string;
|
|
19
|
+
/** Human-readable message — verbatim mirror of Swift errorDescription. */
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
22
|
+
/** Options for validateManifest. */
|
|
23
|
+
export interface ValidateManifestOptions {
|
|
24
|
+
/**
|
|
25
|
+
* 'manifestOnly' (default): pure JSON rules, no filesystem access required.
|
|
26
|
+
* 'runtimeLoadable': additionally checks file existence and path containment;
|
|
27
|
+
* rootDir must be provided.
|
|
28
|
+
*/
|
|
29
|
+
phase?: 'manifestOnly' | 'runtimeLoadable';
|
|
30
|
+
/** Absolute path to the plugin root directory. Required for 'runtimeLoadable'. */
|
|
31
|
+
rootDir?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* All 26 validation error codes, verbatim camelCase mirror of Swift ValidationError cases.
|
|
35
|
+
* Parity with native is enforced by scripts/checks/check-plugin-manifest-validator-parity.sh.
|
|
36
|
+
*/
|
|
37
|
+
export declare const MANIFEST_VALIDATION_CODES: readonly ["unsupportedSchemaVersion", "missingManifest", "missingRuntimeEntry", "missingUIRoot", "legacyFieldNotSupported", "duplicateAttachmentRendererID", "duplicateDetectorID", "duplicateActionID", "emptyPluginID", "emptyPluginTitle", "installEntryMissing", "installEntryOutsideRoot", "rendererTitleEmpty", "detectorTitleEmpty", "actionTitleEmpty", "detectorMissingSupportedInputKinds", "detectorAttachmentTypesEmpty", "detectorAttachmentTypeNotRenderable", "detectorAttachmentTypeOutsideNamespace", "rendererUIEntryMissing", "rendererUIEntryOutsideRoot", "actionMissingSupportedItemTypes", "actionUIEntryMissing", "actionUIEntryOutsideRoot", "detectorInvalidSupportedInputKind", "actionInvalidSupportedItemType"];
|
|
38
|
+
/**
|
|
39
|
+
* Validate a parsed manifest JSON object against the Clipbus plugin manifest rules.
|
|
40
|
+
*
|
|
41
|
+
* Mirrors the validation rules in PluginManifestValidator.swift. Unlike native
|
|
42
|
+
* (which throws on the first error), this function collects ALL issues and
|
|
43
|
+
* returns them as an array — authors see the full picture in one pass.
|
|
44
|
+
*
|
|
45
|
+
* @param manifest - Parsed manifest.json content (result of JSON.parse).
|
|
46
|
+
* @param options - Validation options.
|
|
47
|
+
* @returns Array of issues; empty array means the manifest is valid.
|
|
48
|
+
*/
|
|
49
|
+
export declare function validateManifest(manifest: unknown, options?: ValidateManifestOptions): ManifestValidationIssue[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @clipbus/plugin-sdk — manifest validation
|
|
3
|
+
*
|
|
4
|
+
* JavaScript mirror of the native PluginManifestValidator (Swift).
|
|
5
|
+
* Authority: platform/macos/Sources/Services/Impl/Plugins/PluginManifestValidator.swift
|
|
6
|
+
*
|
|
7
|
+
* Parity: scripts/checks/check-plugin-manifest-validator-parity.sh enforces that
|
|
8
|
+
* MANIFEST_VALIDATION_CODES equals the Swift ValidationError case set, and that
|
|
9
|
+
* CANONICAL_ITEM_TYPES equals ClipboardItemType.swift enum case raw values.
|
|
10
|
+
*
|
|
11
|
+
* Design difference from native: native throws on the first error (fail-fast); this
|
|
12
|
+
* function collects ALL issues and returns them — better DX for development so that
|
|
13
|
+
* authors see the full picture in one pass.
|
|
14
|
+
*/
|
|
15
|
+
/** A single manifest validation issue, analogous to Swift ValidationError. */
|
|
16
|
+
export interface ManifestValidationIssue {
|
|
17
|
+
/** Error code — verbatim Swift ValidationError case name (camelCase). */
|
|
18
|
+
code: string;
|
|
19
|
+
/** Human-readable message — verbatim mirror of Swift errorDescription. */
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
22
|
+
/** Options for validateManifest. */
|
|
23
|
+
export interface ValidateManifestOptions {
|
|
24
|
+
/**
|
|
25
|
+
* 'manifestOnly' (default): pure JSON rules, no filesystem access required.
|
|
26
|
+
* 'runtimeLoadable': additionally checks file existence and path containment;
|
|
27
|
+
* rootDir must be provided.
|
|
28
|
+
*/
|
|
29
|
+
phase?: 'manifestOnly' | 'runtimeLoadable';
|
|
30
|
+
/** Absolute path to the plugin root directory. Required for 'runtimeLoadable'. */
|
|
31
|
+
rootDir?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* All 26 validation error codes, verbatim camelCase mirror of Swift ValidationError cases.
|
|
35
|
+
* Parity with native is enforced by scripts/checks/check-plugin-manifest-validator-parity.sh.
|
|
36
|
+
*/
|
|
37
|
+
export declare const MANIFEST_VALIDATION_CODES: readonly ["unsupportedSchemaVersion", "missingManifest", "missingRuntimeEntry", "missingUIRoot", "legacyFieldNotSupported", "duplicateAttachmentRendererID", "duplicateDetectorID", "duplicateActionID", "emptyPluginID", "emptyPluginTitle", "installEntryMissing", "installEntryOutsideRoot", "rendererTitleEmpty", "detectorTitleEmpty", "actionTitleEmpty", "detectorMissingSupportedInputKinds", "detectorAttachmentTypesEmpty", "detectorAttachmentTypeNotRenderable", "detectorAttachmentTypeOutsideNamespace", "rendererUIEntryMissing", "rendererUIEntryOutsideRoot", "actionMissingSupportedItemTypes", "actionUIEntryMissing", "actionUIEntryOutsideRoot", "detectorInvalidSupportedInputKind", "actionInvalidSupportedItemType"];
|
|
38
|
+
/**
|
|
39
|
+
* Validate a parsed manifest JSON object against the Clipbus plugin manifest rules.
|
|
40
|
+
*
|
|
41
|
+
* Mirrors the validation rules in PluginManifestValidator.swift. Unlike native
|
|
42
|
+
* (which throws on the first error), this function collects ALL issues and
|
|
43
|
+
* returns them as an array — authors see the full picture in one pass.
|
|
44
|
+
*
|
|
45
|
+
* @param manifest - Parsed manifest.json content (result of JSON.parse).
|
|
46
|
+
* @param options - Validation options.
|
|
47
|
+
* @returns Array of issues; empty array means the manifest is valid.
|
|
48
|
+
*/
|
|
49
|
+
export declare function validateManifest(manifest: unknown, options?: ValidateManifestOptions): ManifestValidationIssue[];
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// src/validate/index.ts
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
var MANIFEST_VALIDATION_CODES = [
|
|
5
|
+
"unsupportedSchemaVersion",
|
|
6
|
+
"missingManifest",
|
|
7
|
+
"missingRuntimeEntry",
|
|
8
|
+
"missingUIRoot",
|
|
9
|
+
"legacyFieldNotSupported",
|
|
10
|
+
"duplicateAttachmentRendererID",
|
|
11
|
+
"duplicateDetectorID",
|
|
12
|
+
"duplicateActionID",
|
|
13
|
+
"emptyPluginID",
|
|
14
|
+
"emptyPluginTitle",
|
|
15
|
+
"installEntryMissing",
|
|
16
|
+
"installEntryOutsideRoot",
|
|
17
|
+
"rendererTitleEmpty",
|
|
18
|
+
"detectorTitleEmpty",
|
|
19
|
+
"actionTitleEmpty",
|
|
20
|
+
"detectorMissingSupportedInputKinds",
|
|
21
|
+
"detectorAttachmentTypesEmpty",
|
|
22
|
+
"detectorAttachmentTypeNotRenderable",
|
|
23
|
+
"detectorAttachmentTypeOutsideNamespace",
|
|
24
|
+
"rendererUIEntryMissing",
|
|
25
|
+
"rendererUIEntryOutsideRoot",
|
|
26
|
+
"actionMissingSupportedItemTypes",
|
|
27
|
+
"actionUIEntryMissing",
|
|
28
|
+
"actionUIEntryOutsideRoot",
|
|
29
|
+
"detectorInvalidSupportedInputKind",
|
|
30
|
+
"actionInvalidSupportedItemType"
|
|
31
|
+
];
|
|
32
|
+
var CANONICAL_ITEM_TYPES = ["text", "image", "path_reference"];
|
|
33
|
+
function asRecord(val) {
|
|
34
|
+
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
|
|
35
|
+
return val;
|
|
36
|
+
}
|
|
37
|
+
return void 0;
|
|
38
|
+
}
|
|
39
|
+
function asString(val) {
|
|
40
|
+
return typeof val === "string" ? val : void 0;
|
|
41
|
+
}
|
|
42
|
+
function asStringArray(val) {
|
|
43
|
+
if (!Array.isArray(val)) return [];
|
|
44
|
+
return val.filter((v) => typeof v === "string");
|
|
45
|
+
}
|
|
46
|
+
function asRecordArray(val) {
|
|
47
|
+
if (!Array.isArray(val)) return [];
|
|
48
|
+
return val.filter((v) => asRecord(v) !== void 0);
|
|
49
|
+
}
|
|
50
|
+
function issue(code, message) {
|
|
51
|
+
return { code, message };
|
|
52
|
+
}
|
|
53
|
+
function isInsideRoot(entryRelative, rootAbsolute) {
|
|
54
|
+
const resolvedEntry = path.resolve(rootAbsolute, entryRelative);
|
|
55
|
+
return resolvedEntry === rootAbsolute || resolvedEntry.startsWith(rootAbsolute + path.sep);
|
|
56
|
+
}
|
|
57
|
+
function validateSchemaVersion(raw, issues) {
|
|
58
|
+
const version = typeof raw["schemaVersion"] === "number" ? raw["schemaVersion"] : 0;
|
|
59
|
+
if (version !== 2) {
|
|
60
|
+
issues.push(issue("unsupportedSchemaVersion", `Unsupported plugin schema version: ${version}`));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
var LEGACY_RENDERER_FIELDS = ["displayName", "defaultEnabled", "defaultOrderKey", "actions"];
|
|
64
|
+
var LEGACY_DETECTOR_FIELDS = ["displayName", "capabilityID", "defaultEnabled", "timeoutMs"];
|
|
65
|
+
var LEGACY_ACTION_FIELDS = ["displayName", "actionID", "defaultEnabled", "defaultOrderKey", "revealButtons"];
|
|
66
|
+
function validateLegacyFields(raw, issues) {
|
|
67
|
+
const plugin = asRecord(raw["plugin"]);
|
|
68
|
+
if (plugin && "displayName" in plugin) {
|
|
69
|
+
issues.push(issue("legacyFieldNotSupported", `Legacy manifest field is not supported in v2: plugin.displayName`));
|
|
70
|
+
}
|
|
71
|
+
const renderers = asRecordArray(raw["attachmentRenderers"]);
|
|
72
|
+
for (let i = 0; i < renderers.length; i++) {
|
|
73
|
+
for (const field of LEGACY_RENDERER_FIELDS) {
|
|
74
|
+
if (field in renderers[i]) {
|
|
75
|
+
issues.push(issue("legacyFieldNotSupported", `Legacy manifest field is not supported in v2: attachmentRenderers[${i}].${field}`));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const detectors = asRecordArray(raw["detectors"]);
|
|
80
|
+
for (let i = 0; i < detectors.length; i++) {
|
|
81
|
+
for (const field of LEGACY_DETECTOR_FIELDS) {
|
|
82
|
+
if (field in detectors[i]) {
|
|
83
|
+
issues.push(issue("legacyFieldNotSupported", `Legacy manifest field is not supported in v2: detectors[${i}].${field}`));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const actions = asRecordArray(raw["actions"]);
|
|
88
|
+
for (let i = 0; i < actions.length; i++) {
|
|
89
|
+
for (const field of LEGACY_ACTION_FIELDS) {
|
|
90
|
+
if (field in actions[i]) {
|
|
91
|
+
issues.push(issue("legacyFieldNotSupported", `Legacy manifest field is not supported in v2: actions[${i}].${field}`));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function validateCanonicalItemTypeSpellings(raw, issues) {
|
|
97
|
+
for (const detector of asRecordArray(raw["detectors"])) {
|
|
98
|
+
const detectorID = asString(detector["id"]) ?? "";
|
|
99
|
+
for (const value of asStringArray(detector["supportedInputKinds"])) {
|
|
100
|
+
if (!CANONICAL_ITEM_TYPES.includes(value)) {
|
|
101
|
+
issues.push(issue("detectorInvalidSupportedInputKind", `Detector supported input kinds contain unsupported value: ${detectorID} -> ${value}`));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const action of asRecordArray(raw["actions"])) {
|
|
106
|
+
const actionID = asString(action["id"]) ?? "";
|
|
107
|
+
for (const value of asStringArray(action["supportedItemTypes"])) {
|
|
108
|
+
if (!CANONICAL_ITEM_TYPES.includes(value)) {
|
|
109
|
+
issues.push(issue("actionInvalidSupportedItemType", `Action supported item types contain unsupported value: ${actionID} -> ${value}`));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function validateDetectors(detectors, pluginID, renderableAttachmentTypes, issues) {
|
|
115
|
+
const seenIDs = /* @__PURE__ */ new Set();
|
|
116
|
+
const prefix = pluginID + ".";
|
|
117
|
+
for (const detector of detectors) {
|
|
118
|
+
const id = asString(detector["id"]) ?? "";
|
|
119
|
+
if (seenIDs.has(id)) {
|
|
120
|
+
issues.push(issue("duplicateDetectorID", `Duplicate detector id: ${id}`));
|
|
121
|
+
} else {
|
|
122
|
+
seenIDs.add(id);
|
|
123
|
+
}
|
|
124
|
+
const title = asString(detector["title"]) ?? "";
|
|
125
|
+
if (title.trim().length === 0) {
|
|
126
|
+
issues.push(issue("detectorTitleEmpty", `Detector title is empty: ${id}`));
|
|
127
|
+
}
|
|
128
|
+
const supportedInputKinds = asStringArray(detector["supportedInputKinds"]);
|
|
129
|
+
if (supportedInputKinds.length === 0) {
|
|
130
|
+
issues.push(issue("detectorMissingSupportedInputKinds", `Detector is missing supported input kinds: ${id}`));
|
|
131
|
+
}
|
|
132
|
+
const attachmentTypes = asStringArray(detector["attachmentTypes"]);
|
|
133
|
+
if (attachmentTypes.length === 0) {
|
|
134
|
+
issues.push(issue("detectorAttachmentTypesEmpty", `Detector attachment types are empty: ${id}`));
|
|
135
|
+
}
|
|
136
|
+
for (const attachmentType of attachmentTypes) {
|
|
137
|
+
if (!attachmentType.startsWith(prefix)) {
|
|
138
|
+
issues.push(issue("detectorAttachmentTypeOutsideNamespace", `Detector attachment type is outside plugin namespace: ${id} -> ${attachmentType}`));
|
|
139
|
+
} else if (!renderableAttachmentTypes.has(attachmentType)) {
|
|
140
|
+
issues.push(issue("detectorAttachmentTypeNotRenderable", `Detector attachment type is not renderable: ${id} -> ${attachmentType}`));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function validateActions(actions, issues) {
|
|
146
|
+
const seenIDs = /* @__PURE__ */ new Set();
|
|
147
|
+
for (const action of actions) {
|
|
148
|
+
const id = asString(action["id"]) ?? "";
|
|
149
|
+
if (seenIDs.has(id)) {
|
|
150
|
+
issues.push(issue("duplicateActionID", `Duplicate action id: ${id}`));
|
|
151
|
+
} else {
|
|
152
|
+
seenIDs.add(id);
|
|
153
|
+
}
|
|
154
|
+
const title = asString(action["title"]) ?? "";
|
|
155
|
+
if (title.trim().length === 0) {
|
|
156
|
+
issues.push(issue("actionTitleEmpty", `Action title is empty: ${id}`));
|
|
157
|
+
}
|
|
158
|
+
const supportedItemTypes = asStringArray(action["supportedItemTypes"]);
|
|
159
|
+
if (supportedItemTypes.length === 0) {
|
|
160
|
+
issues.push(issue("actionMissingSupportedItemTypes", `Action is missing supported item types: ${id}`));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function validateRootEntry(rawEntry, rootDir, issues, missingCode, missingMessage, outsideCode, outsideMessage) {
|
|
165
|
+
const trimmed = rawEntry.trim();
|
|
166
|
+
if (trimmed.length === 0) {
|
|
167
|
+
issues.push(issue(missingCode, missingMessage(rawEntry)));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!isInsideRoot(trimmed, rootDir)) {
|
|
171
|
+
issues.push(issue(outsideCode, outsideMessage(trimmed)));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (!existsSync(path.resolve(rootDir, trimmed))) {
|
|
175
|
+
issues.push(issue(missingCode, missingMessage(trimmed)));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function validateUIEntry(uiEntry, uiRootDir, issues, missingCode, missingMessage, outsideCode, outsideMessage) {
|
|
179
|
+
if (!uiEntry || uiEntry.trim().length === 0) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const trimmed = uiEntry.trim();
|
|
183
|
+
if (!isInsideRoot(trimmed, uiRootDir)) {
|
|
184
|
+
issues.push(issue(outsideCode, outsideMessage(trimmed)));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (!existsSync(path.resolve(uiRootDir, trimmed))) {
|
|
188
|
+
issues.push(issue(missingCode, missingMessage(trimmed)));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function validateManifest(manifest, options = {}) {
|
|
192
|
+
const issues = [];
|
|
193
|
+
const phase = options.phase ?? "manifestOnly";
|
|
194
|
+
const rootDir = path.resolve(options.rootDir ?? process.cwd());
|
|
195
|
+
const raw = asRecord(manifest);
|
|
196
|
+
if (!raw) {
|
|
197
|
+
issues.push(issue("missingManifest", "Missing manifest.json"));
|
|
198
|
+
return issues;
|
|
199
|
+
}
|
|
200
|
+
validateSchemaVersion(raw, issues);
|
|
201
|
+
validateLegacyFields(raw, issues);
|
|
202
|
+
validateCanonicalItemTypeSpellings(raw, issues);
|
|
203
|
+
const plugin = asRecord(raw["plugin"]) ?? {};
|
|
204
|
+
const pluginID = asString(plugin["id"]) ?? "";
|
|
205
|
+
const pluginTitle = asString(plugin["title"]) ?? "";
|
|
206
|
+
if (pluginID.trim().length === 0) {
|
|
207
|
+
issues.push(issue("emptyPluginID", "Plugin id is empty"));
|
|
208
|
+
}
|
|
209
|
+
if (pluginTitle.trim().length === 0) {
|
|
210
|
+
issues.push(issue("emptyPluginTitle", "Plugin title is empty"));
|
|
211
|
+
}
|
|
212
|
+
const renderers = asRecordArray(raw["attachmentRenderers"]);
|
|
213
|
+
const renderableAttachmentTypes = /* @__PURE__ */ new Set();
|
|
214
|
+
const seenRendererIDs = /* @__PURE__ */ new Set();
|
|
215
|
+
for (const renderer of renderers) {
|
|
216
|
+
const id = asString(renderer["id"]) ?? "";
|
|
217
|
+
const attachmentType = asString(renderer["attachmentType"]) ?? "";
|
|
218
|
+
if (attachmentType) renderableAttachmentTypes.add(attachmentType);
|
|
219
|
+
if (seenRendererIDs.has(id)) {
|
|
220
|
+
issues.push(issue("duplicateAttachmentRendererID", `Duplicate attachment renderer id: ${id}`));
|
|
221
|
+
} else {
|
|
222
|
+
seenRendererIDs.add(id);
|
|
223
|
+
}
|
|
224
|
+
const title = asString(renderer["title"]) ?? "";
|
|
225
|
+
if (title.trim().length === 0) {
|
|
226
|
+
issues.push(issue("rendererTitleEmpty", `Attachment renderer title is empty: ${id}`));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
validateDetectors(asRecordArray(raw["detectors"]), pluginID, renderableAttachmentTypes, issues);
|
|
230
|
+
validateActions(asRecordArray(raw["actions"]), issues);
|
|
231
|
+
if (phase !== "runtimeLoadable") {
|
|
232
|
+
return issues;
|
|
233
|
+
}
|
|
234
|
+
const install = asRecord(raw["install"]);
|
|
235
|
+
if (install) {
|
|
236
|
+
const rawEntry = install["entry"] !== void 0 ? asString(install["entry"]) ?? "" : "";
|
|
237
|
+
validateRootEntry(
|
|
238
|
+
rawEntry.trim(),
|
|
239
|
+
rootDir,
|
|
240
|
+
issues,
|
|
241
|
+
"installEntryMissing",
|
|
242
|
+
(p) => `Missing install hook entry: ${p}`,
|
|
243
|
+
"installEntryOutsideRoot",
|
|
244
|
+
(p) => `Install hook entry is outside plugin root: ${p}`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const runtime = asRecord(raw["runtime"]) ?? {};
|
|
248
|
+
const nodeEntry = asString(runtime["nodeEntry"]) ?? "";
|
|
249
|
+
const uiRoot = asString(runtime["uiRoot"]) ?? "";
|
|
250
|
+
if (!existsSync(path.resolve(rootDir, nodeEntry))) {
|
|
251
|
+
issues.push(issue("missingRuntimeEntry", `Missing runtime entry: ${nodeEntry}`));
|
|
252
|
+
}
|
|
253
|
+
const uiRootAbs = path.resolve(rootDir, uiRoot);
|
|
254
|
+
const declaresUISurface = renderers.length > 0 || asRecordArray(raw["actions"]).some((a) => {
|
|
255
|
+
const entry = asString(a["uiEntry"])?.trim();
|
|
256
|
+
return entry !== void 0 && entry.length > 0;
|
|
257
|
+
});
|
|
258
|
+
if (declaresUISurface && !existsSync(uiRootAbs)) {
|
|
259
|
+
issues.push(issue("missingUIRoot", `Missing UI root: ${uiRoot}`));
|
|
260
|
+
}
|
|
261
|
+
for (const renderer of renderers) {
|
|
262
|
+
validateUIEntry(
|
|
263
|
+
asString(renderer["uiEntry"]),
|
|
264
|
+
uiRootAbs,
|
|
265
|
+
issues,
|
|
266
|
+
"rendererUIEntryMissing",
|
|
267
|
+
(p) => `Missing renderer UI entry: ${p}`,
|
|
268
|
+
"rendererUIEntryOutsideRoot",
|
|
269
|
+
(p) => `Renderer UI entry is outside uiRoot: ${p}`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
for (const action of asRecordArray(raw["actions"])) {
|
|
273
|
+
validateUIEntry(
|
|
274
|
+
asString(action["uiEntry"]),
|
|
275
|
+
uiRootAbs,
|
|
276
|
+
issues,
|
|
277
|
+
"actionUIEntryMissing",
|
|
278
|
+
(p) => `Missing action UI entry: ${p}`,
|
|
279
|
+
"actionUIEntryOutsideRoot",
|
|
280
|
+
(p) => `Action UI entry is outside uiRoot: ${p}`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return issues;
|
|
284
|
+
}
|
|
285
|
+
export {
|
|
286
|
+
MANIFEST_VALIDATION_CODES,
|
|
287
|
+
validateManifest
|
|
288
|
+
};
|
package/docs/manifest.md
CHANGED
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
| `runtime.nodeEntry` | `string` | 是 | Node runtime 入口(编译后 `.cjs`) |
|
|
48
48
|
| `runtime.uiRoot` | `string` | 是 | UI 根目录;所有 `uiEntry` 相对此目录 |
|
|
49
49
|
|
|
50
|
+
> **关于 `uiRoot` 目录存在性**:`uiRoot` 是必填*声明*,但其指向目录(如 `dist/ui`)只在插件声明 UI 面时才需在磁盘存在——即有任意 `attachmentRenderers`,或任意带 `uiEntry` 的 `action`。纯 auto-run-action 插件(无 renderer / detector / action uiEntry)构建后无需产出 `dist/ui`,`clipbus-validate-manifest --runtime` 与宿主加载期都不会因其缺失而失败。
|
|
51
|
+
|
|
50
52
|
---
|
|
51
53
|
|
|
52
54
|
## `permissions`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clipbus/plugin-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed SDK for authoring Clipbus plugins — runtime (Node.js) and UI (WebView) helpers generated from the Clipbus plugin wire contract.",
|
|
6
6
|
"keywords": [
|
|
@@ -48,8 +48,21 @@
|
|
|
48
48
|
"types": "./dist/dom/index.d.cts",
|
|
49
49
|
"default": "./dist/dom/index.cjs"
|
|
50
50
|
}
|
|
51
|
+
},
|
|
52
|
+
"./validate": {
|
|
53
|
+
"import": {
|
|
54
|
+
"types": "./dist/validate/index.d.ts",
|
|
55
|
+
"default": "./dist/validate/index.js"
|
|
56
|
+
},
|
|
57
|
+
"require": {
|
|
58
|
+
"types": "./dist/validate/index.d.cts",
|
|
59
|
+
"default": "./dist/validate/index.cjs"
|
|
60
|
+
}
|
|
51
61
|
}
|
|
52
62
|
},
|
|
63
|
+
"bin": {
|
|
64
|
+
"clipbus-validate-manifest": "bin/validate-manifest.mjs"
|
|
65
|
+
},
|
|
53
66
|
"scripts": {
|
|
54
67
|
"prepare": "node ./scripts/build.mjs",
|
|
55
68
|
"build": "node ./scripts/build.mjs",
|
|
@@ -59,6 +72,7 @@
|
|
|
59
72
|
},
|
|
60
73
|
"files": [
|
|
61
74
|
"dist",
|
|
75
|
+
"bin",
|
|
62
76
|
"SPECIFICATION.md",
|
|
63
77
|
"README.md",
|
|
64
78
|
"API.md",
|