@pptb/types 1.2.3 → 1.2.4-beta.1

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.
@@ -1,244 +1,2 @@
1
1
  #!/usr/bin/env node
2
- // @ts-check
3
- "use strict";
4
-
5
- /**
6
- * pptb-validate - CLI tool for validating Power Platform ToolBox tool packages
7
- *
8
- * Usage:
9
- * pptb-validate [options] [path/to/package.json]
10
- *
11
- * Options:
12
- * --skip-url-checks Skip URL accessibility checks (faster, offline-friendly)
13
- * --json Output results as JSON
14
- * --help, -h Show help information
15
- */
16
-
17
- const fs = require("fs");
18
- const path = require("path");
19
- const { validatePackageJson, validatePPTBConfig, KNOWN_CAPABILITY_TAGS } = require("../lib/validate");
20
-
21
- // ANSI colour helpers – gracefully degrade when colours are unsupported
22
- const NO_COLOR = !process.stdout.isTTY || process.env.NO_COLOR;
23
- const c = {
24
- red: (s) => (NO_COLOR ? s : `\x1b[31m${s}\x1b[0m`),
25
- yellow: (s) => (NO_COLOR ? s : `\x1b[33m${s}\x1b[0m`),
26
- green: (s) => (NO_COLOR ? s : `\x1b[32m${s}\x1b[0m`),
27
- cyan: (s) => (NO_COLOR ? s : `\x1b[36m${s}\x1b[0m`),
28
- bold: (s) => (NO_COLOR ? s : `\x1b[1m${s}\x1b[0m`),
29
- dim: (s) => (NO_COLOR ? s : `\x1b[2m${s}\x1b[0m`),
30
- };
31
-
32
- function printHelp() {
33
- console.log(`
34
- ${c.bold("pptb-validate")} – Power Platform ToolBox tool validator
35
-
36
- ${c.bold("USAGE")}
37
- pptb-validate [options] [path/to/package.json]
38
-
39
- When no path is given the tool looks for ${c.cyan("package.json")} in the current
40
- working directory. If a ${c.cyan("pptb.config.json")} file exists in the same directory
41
- it is automatically validated as well.
42
-
43
- ${c.bold("OPTIONS")}
44
- ${c.cyan("--skip-url-checks")} Skip URL reachability checks (faster, works offline)
45
- ${c.cyan("--json")} Print results as a JSON object instead of human-readable text
46
- ${c.cyan("--help")}, ${c.cyan("-h")} Show this help message
47
-
48
- ${c.bold("ADD TO YOUR TOOL'S package.json")}
49
- ${c.dim(`"scripts": {
50
- "validate": "pptb-validate"
51
- }`)}
52
-
53
- Then run: ${c.cyan("npm run validate")}
54
-
55
- ${c.bold("EXAMPLES")}
56
- npm run validate
57
- npm run validate ./my-tool/package.json
58
- npm run validate --skip-url-checks
59
- npm run validate --json
60
- `);
61
- }
62
-
63
- async function main() {
64
- const args = process.argv.slice(2);
65
-
66
- if (args.includes("--help") || args.includes("-h")) {
67
- printHelp();
68
- process.exit(0);
69
- }
70
-
71
- const skipUrlChecks = args.includes("--skip-url-checks");
72
- const jsonOutput = args.includes("--json");
73
-
74
- // Find the package.json path from positional args (skip flags)
75
- const positional = args.filter((a) => !a.startsWith("-"));
76
- let packageJsonPath = positional[0] || path.join(process.cwd(), "package.json");
77
-
78
- // Resolve to absolute path
79
- if (!path.isAbsolute(packageJsonPath)) {
80
- packageJsonPath = path.resolve(process.cwd(), packageJsonPath);
81
- }
82
-
83
- // Derive pptb.config.json path from the same directory as package.json
84
- const toolDir = path.dirname(packageJsonPath);
85
- const pptbConfigPath = path.join(toolDir, "pptb.config.json");
86
-
87
- // --- Load package.json ---
88
- if (!fs.existsSync(packageJsonPath)) {
89
- if (jsonOutput) {
90
- console.log(JSON.stringify({ valid: false, errors: [`package.json not found at: ${packageJsonPath}`], warnings: [] }, null, 2));
91
- } else {
92
- console.error(c.red(`✖ package.json not found at: ${packageJsonPath}`));
93
- }
94
- process.exit(1);
95
- }
96
-
97
- let packageJson;
98
- try {
99
- packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
100
- } catch (err) {
101
- const message = err instanceof Error ? err.message : String(err);
102
- if (jsonOutput) {
103
- console.log(JSON.stringify({ valid: false, errors: [`Failed to parse package.json: ${message}`], warnings: [] }, null, 2));
104
- } else {
105
- console.error(c.red(`✖ Failed to parse package.json: ${message}`));
106
- }
107
- process.exit(1);
108
- }
109
-
110
- // --- Load pptb.config.json (optional) ---
111
- let pptbConfig = null;
112
- let pptbConfigParseError = null;
113
- if (fs.existsSync(pptbConfigPath)) {
114
- try {
115
- pptbConfig = JSON.parse(fs.readFileSync(pptbConfigPath, "utf8"));
116
- } catch (err) {
117
- pptbConfigParseError = err instanceof Error ? err.message : String(err);
118
- }
119
- }
120
-
121
- // --- Run validation ---
122
- if (!jsonOutput) {
123
- console.log();
124
- console.log(c.bold("Power Platform ToolBox – Tool Validator"));
125
- console.log(c.dim("─".repeat(45)));
126
- console.log(c.dim(`File: ${packageJsonPath}`));
127
- if (pptbConfig !== null) {
128
- console.log(c.dim(`Config: ${pptbConfigPath}`));
129
- }
130
- if (skipUrlChecks) {
131
- console.log(c.yellow("⚠ URL reachability checks are skipped"));
132
- }
133
- console.log();
134
- }
135
-
136
- let packageResult;
137
- try {
138
- packageResult = await validatePackageJson(packageJson, { skipUrlChecks });
139
- } catch (err) {
140
- const message = err instanceof Error ? err.message : String(err);
141
- if (jsonOutput) {
142
- console.log(JSON.stringify({ valid: false, errors: [`Unexpected validation error: ${message}`], warnings: [] }, null, 2));
143
- } else {
144
- console.error(c.red(`✖ Unexpected validation error: ${message}`));
145
- }
146
- process.exit(1);
147
- }
148
-
149
- // --- Validate pptb.config.json if present ---
150
- let configResult = null;
151
- if (pptbConfigParseError !== null) {
152
- configResult = { valid: false, errors: [`Failed to parse pptb.config.json: ${pptbConfigParseError}`], warnings: [] };
153
- } else if (pptbConfig !== null) {
154
- configResult = validatePPTBConfig(pptbConfig);
155
- }
156
-
157
- // Merge results for overall pass/fail
158
- const allErrors = [...packageResult.errors, ...(configResult ? configResult.errors : [])];
159
- const allWarnings = [...packageResult.warnings, ...(configResult ? configResult.warnings : [])];
160
- const overallValid = packageResult.valid && (configResult === null || configResult.valid);
161
-
162
- // --- Output results ---
163
- if (jsonOutput) {
164
- const output = {
165
- valid: overallValid,
166
- errors: allErrors,
167
- warnings: allWarnings,
168
- packageInfo: packageResult.packageInfo,
169
- configInfo: configResult ? configResult.packageInfo : undefined,
170
- };
171
- console.log(JSON.stringify(output, null, 2));
172
- process.exit(overallValid ? 0 : 1);
173
- }
174
-
175
- // Human-readable output – package.json section
176
- if (packageResult.errors.length > 0) {
177
- console.log(c.bold(c.red(`package.json Errors (${packageResult.errors.length})`)));
178
- packageResult.errors.forEach((e) => console.log(` ${c.red("✖")} ${e}`));
179
- console.log();
180
- }
181
-
182
- if (packageResult.warnings.length > 0) {
183
- console.log(c.bold(c.yellow(`package.json Warnings (${packageResult.warnings.length})`)));
184
- packageResult.warnings.forEach((w) => console.log(` ${c.yellow("⚠")} ${w}`));
185
- console.log();
186
- }
187
-
188
- // Human-readable output – pptb.config.json section
189
- if (configResult !== null) {
190
- if (configResult.errors.length > 0) {
191
- console.log(c.bold(c.red(`pptb.config.json Errors (${configResult.errors.length})`)));
192
- configResult.errors.forEach((e) => console.log(` ${c.red("✖")} ${e}`));
193
- console.log();
194
- }
195
-
196
- if (configResult.warnings.length > 0) {
197
- console.log(c.bold(c.yellow(`pptb.config.json Warnings (${configResult.warnings.length})`)));
198
- configResult.warnings.forEach((w) => console.log(` ${c.yellow("⚠")} ${w}`));
199
- console.log();
200
- }
201
- }
202
-
203
- if (overallValid) {
204
- const info = packageResult.packageInfo;
205
- console.log(c.green(c.bold("✔ Validation passed")));
206
- console.log();
207
- console.log(c.bold("Package summary"));
208
- console.log(c.dim("─".repeat(45)));
209
- console.log(` Name : ${info.name}`);
210
- console.log(` Version : ${info.version}`);
211
- console.log(` Display name: ${info.displayName}`);
212
- console.log(` Description : ${info.description}`);
213
- console.log(` License : ${info.license}`);
214
- console.log(` Contributors: ${info.contributors.map((contributor) => contributor.name).join(", ")}`);
215
- if (info.icon) {
216
- console.log(` Icon : ${info.icon}`);
217
- }
218
- if (info.features) {
219
- console.log(` Features : multiConnection=${info.features.multiConnection}${info.features.minAPI ? `, minAPI=${info.features.minAPI}` : ""}`);
220
- }
221
- if (configResult !== null && configResult.packageInfo && configResult.packageInfo.invocation) {
222
- const inv = configResult.packageInfo.invocation;
223
- console.log(` Invocation : version=${inv.version}`);
224
- if (Array.isArray(inv.capabilities) && inv.capabilities.length > 0) {
225
- console.log(` Capabilities: ${inv.capabilities.join(", ")}`);
226
- }
227
- }
228
- console.log();
229
- console.log(c.dim(`Known capability tags: ${KNOWN_CAPABILITY_TAGS.join(", ")}`));
230
- console.log();
231
- } else {
232
- console.log(c.red(c.bold("✖ Validation failed")));
233
- console.log();
234
- console.log(c.dim("Fix the errors listed above and re-run pptb-validate before publishing."));
235
- console.log();
236
- }
237
-
238
- process.exit(overallValid ? 0 : 1);
239
- }
240
-
241
- main().catch((err) => {
242
- console.error(c.red(`✖ Fatal error: ${err instanceof Error ? err.message : String(err)}`));
243
- process.exit(1);
244
- });
2
+ require("@pptb/validate/cli");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pptb/types",
3
- "version": "1.2.3",
3
+ "version": "1.2.4-beta.1",
4
4
  "description": "Type definitions for Power Platform ToolBox APIs and validity checks for tool packages",
5
5
  "main": "index.d.ts",
6
6
  "types": "index.d.ts",
@@ -22,6 +22,9 @@
22
22
  "bin": {
23
23
  "pptb-validate": "./bin/pptb-validate.js"
24
24
  },
25
+ "dependencies": {
26
+ "@pptb/validate": "^0.0.1"
27
+ },
25
28
  "files": [
26
29
  "index.d.ts",
27
30
  "toolboxAPI.d.ts",
package/lib/validate.js DELETED
@@ -1,486 +0,0 @@
1
- // @ts-check
2
- "use strict";
3
-
4
- /**
5
- * Tool validation logic for Power Platform ToolBox tools.
6
- * Mirrors the validation rules used during the official review process.
7
- */
8
-
9
- /** @typedef {{ name: string; url?: string }} Contributor */
10
- /** @typedef {{ "connect-src"?: string[]; "script-src"?: string[]; "style-src"?: string[]; "img-src"?: string[]; "font-src"?: string[]; "frame-src"?: string[]; "media-src"?: string[] }} CspExceptions */
11
- /** @typedef {{ repository?: string; website?: string; funding?: string; readmeUrl?: string }} Configurations */
12
- /** @typedef {{ multiConnection?: "required" | "optional" | "none"; minAPI?: string }} Features */
13
- /**
14
- * @typedef {{
15
- * name: string;
16
- * version: string;
17
- * displayName?: string;
18
- * description?: string;
19
- * contributors?: Contributor[];
20
- * cspExceptions?: CspExceptions;
21
- * license?: string;
22
- * icon?: string;
23
- * configurations?: Configurations;
24
- * features?: Features;
25
- * }} ToolPackageJson
26
- */
27
- /**
28
- * @typedef {{
29
- * valid: boolean;
30
- * errors: string[];
31
- * warnings: string[];
32
- * packageInfo?: object;
33
- * }} ValidationResult
34
- */
35
-
36
- /**
37
- * @typedef {{ type?: string; enum?: string[]; items?: object }} JsonSchemaProperty
38
- * @typedef {{ properties?: Record<string, JsonSchemaProperty> }} JsonSchemaObject
39
- * @typedef {{
40
- * version: string;
41
- * prefill?: JsonSchemaObject;
42
- * returnTopic?: JsonSchemaObject;
43
- * }} InvocationConfig
44
- * @typedef {{
45
- * invocation?: InvocationConfig;
46
- * }} PPTBConfig
47
- */
48
-
49
- // List of approved open source licenses
50
- const APPROVED_LICENSES = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "GPL-2.0", "GPL-3.0", "LGPL-3.0", "ISC", "AGPL-3.0-only"];
51
-
52
- // Valid multiConnection values
53
- const VALID_MULTI_CONNECTION_VALUES = ["required", "optional", "none"];
54
-
55
- // Semver regex for minAPI validation
56
- const SEMVER_REGEX = /^\d+\.\d+\.\d+(-[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$/;
57
-
58
- /**
59
- * Built-in list of well-known capability tags.
60
- * Mirrors the entries in the Supabase `capability_tags` table.
61
- * New tags are added to Supabase first (configurable without a deploy) and are
62
- * then reflected here in the next package release so offline validation stays current.
63
- *
64
- * When a tool declares a tag that is not in this list the validator emits a warning
65
- * (not an error) so existing tools are not broken by registry updates.
66
- *
67
- * **Keep in sync with `BUILT_IN_CAPABILITY_TAGS` in
68
- * `src/main/managers/toolRegistryManager.ts`.** Because this file is a standalone
69
- * Node.js module that cannot import from the Electron/TypeScript source tree, both
70
- * lists must be updated together whenever a new tag is added.
71
- */
72
- const KNOWN_CAPABILITY_TAGS = [
73
- "fetchxml",
74
- "entity-picker",
75
- "record-selector",
76
- "solution-selector",
77
- "webresource-editor",
78
- "plugin-inspector",
79
- "pcf-control-builder",
80
- ];
81
-
82
- /**
83
- * Checks if a string is a valid URL.
84
- * @param {string} url
85
- * @returns {boolean}
86
- */
87
- function isValidUrl(url) {
88
- try {
89
- new URL(url);
90
- return true;
91
- } catch {
92
- return false;
93
- }
94
- }
95
-
96
- /**
97
- * Checks if a URL hostname is a GitHub domain.
98
- * @param {string} url
99
- * @returns {boolean}
100
- */
101
- function isGithubDomain(url) {
102
- try {
103
- const hostname = new URL(url).hostname.toLowerCase();
104
- return hostname === "github.com" || hostname.endsWith(".github.com");
105
- } catch {
106
- return false;
107
- }
108
- }
109
-
110
- /**
111
- * Validates an icon path string.
112
- * @param {string} fieldName
113
- * @param {string} iconPath
114
- * @param {string[]} errors
115
- */
116
- function validateIconPath(fieldName, iconPath, errors) {
117
- if (iconPath.startsWith("http://") || iconPath.startsWith("https://")) {
118
- errors.push(`${fieldName} cannot be an HTTP(S) URL - icons must be bundled under dist`);
119
- return;
120
- }
121
- if (iconPath.startsWith("/")) {
122
- errors.push(`${fieldName} must be a relative path (e.g., 'icon.svg' or 'icons/icon.svg')`);
123
- return;
124
- }
125
- // Reject Windows absolute paths like "C:\..." or "C:/"
126
- if (/^[a-zA-Z]:/.test(iconPath)) {
127
- errors.push(`${fieldName} must be a relative path (e.g., 'icon.svg' or 'icons/icon.svg')`);
128
- return;
129
- }
130
- // Reject backslashes — icon paths must use forward slashes
131
- if (iconPath.includes("\\")) {
132
- errors.push(`${fieldName} must use forward slashes, not backslashes (e.g., 'icons/icon.svg')`);
133
- return;
134
- }
135
- if (iconPath.includes("..")) {
136
- errors.push(`${fieldName} cannot contain '..' (path traversal not allowed)`);
137
- return;
138
- }
139
- if (!iconPath.toLowerCase().endsWith(".svg")) {
140
- errors.push(`${fieldName} must be an SVG file with .svg extension`);
141
- }
142
- }
143
-
144
- /**
145
- * Checks if a URL is accessible by making a HEAD request.
146
- * @param {string} url
147
- * @returns {Promise<boolean>}
148
- */
149
- async function isUrlAccessible(url) {
150
- try {
151
- const response = await fetch(url, { method: "HEAD", redirect: "follow" });
152
- return response.ok;
153
- } catch {
154
- return false;
155
- }
156
- }
157
-
158
- /**
159
- * Validates a tool's package.json against the official review criteria.
160
- *
161
- * @param {ToolPackageJson} packageJson - The parsed package.json object.
162
- * @param {{ skipUrlChecks?: boolean }} [options] - Validation options.
163
- * @returns {Promise<ValidationResult>}
164
- */
165
- async function validatePackageJson(packageJson, options = {}) {
166
- const { skipUrlChecks = false } = options;
167
- const errors = /** @type {string[]} */ ([]);
168
- const warnings = /** @type {string[]} */ ([]);
169
-
170
- // Required fields
171
- if (!packageJson.name || typeof packageJson.name !== "string") {
172
- errors.push("Package name is required and must be a string");
173
- }
174
-
175
- if (!packageJson.version || typeof packageJson.version !== "string") {
176
- errors.push("Package version is required and must be a string");
177
- }
178
-
179
- if (!packageJson.displayName || typeof packageJson.displayName !== "string") {
180
- errors.push("displayName is required and must be a string");
181
- }
182
-
183
- if (!packageJson.description || typeof packageJson.description !== "string") {
184
- errors.push("description is required and must be a string");
185
- }
186
-
187
- // License validation
188
- if (!packageJson.license) {
189
- errors.push("license is required");
190
- } else if (!APPROVED_LICENSES.includes(packageJson.license)) {
191
- errors.push(`License "${packageJson.license}" is not in the approved list. Approved licenses: ${APPROVED_LICENSES.join(", ")}`);
192
- }
193
-
194
- // Icon validation (optional, but must be a relative SVG path if provided)
195
- if (packageJson.icon === undefined || packageJson.icon === null) {
196
- warnings.push("icon is not set; consider adding a bundled SVG icon so your tool displays properly in the marketplace");
197
- } else if (typeof packageJson.icon !== "string") {
198
- errors.push("icon must be a string (relative path to bundled SVG under dist)");
199
- } else {
200
- validateIconPath("icon", packageJson.icon, errors);
201
- }
202
-
203
- // Contributors validation
204
- if (!packageJson.contributors || !Array.isArray(packageJson.contributors)) {
205
- errors.push("contributors is required and must be an array");
206
- } else if (packageJson.contributors.length === 0) {
207
- errors.push("At least one contributor is required");
208
- } else {
209
- packageJson.contributors.forEach((contributor, index) => {
210
- if (!contributor.name || typeof contributor.name !== "string") {
211
- errors.push(`Contributor at index ${index} must have a name`);
212
- }
213
- if (contributor.url && !isValidUrl(contributor.url)) {
214
- warnings.push(`Contributor "${contributor.name}" has an invalid URL`);
215
- }
216
- });
217
- }
218
-
219
- // Configurations validation
220
- if (!packageJson.configurations || typeof packageJson.configurations !== "object") {
221
- errors.push("configurations is required and must include repository and readmeUrl");
222
- } else {
223
- const configs = packageJson.configurations;
224
-
225
- // configurations.iconUrl is no longer supported
226
- if (/** @type {Record<string, unknown>} */ (configs).iconUrl !== undefined) {
227
- errors.push("configurations.iconUrl is no longer supported; use top-level 'icon' for bundled SVG path");
228
- }
229
-
230
- // Repository validation
231
- if (!configs.repository || typeof configs.repository !== "string") {
232
- errors.push("configurations.repository is required and must be a URL");
233
- } else if (!isValidUrl(configs.repository)) {
234
- errors.push("configurations.repository has an invalid URL format");
235
- } else if (!skipUrlChecks) {
236
- const accessible = await isUrlAccessible(configs.repository);
237
- if (!accessible) {
238
- errors.push("configurations.repository URL is not accessible");
239
- }
240
- }
241
-
242
- // Website validation (optional but recommended)
243
- if (!configs.website) {
244
- warnings.push("configurations.website is not set; consider adding a URL where users can learn more about your tool");
245
- } else if (!isValidUrl(configs.website)) {
246
- warnings.push("configurations.website has an invalid URL format");
247
- } else if (!skipUrlChecks) {
248
- const accessible = await isUrlAccessible(configs.website);
249
- if (!accessible) {
250
- warnings.push("configurations.website URL is not accessible");
251
- }
252
- }
253
-
254
- // Funding validation (optional but recommended)
255
- if (configs.funding) {
256
- if (!isValidUrl(configs.funding)) {
257
- warnings.push("configurations.funding has an invalid URL format");
258
- } else if (!skipUrlChecks) {
259
- const accessible = await isUrlAccessible(configs.funding);
260
- if (!accessible) {
261
- warnings.push("configurations.funding URL is not accessible");
262
- }
263
- }
264
- }
265
-
266
- // ReadmeUrl validation
267
- if (!configs.readmeUrl || typeof configs.readmeUrl !== "string") {
268
- errors.push("configurations.readmeUrl is required and must be a URL");
269
- } else if (!isValidUrl(configs.readmeUrl)) {
270
- errors.push("configurations.readmeUrl has an invalid URL format");
271
- } else if (isGithubDomain(configs.readmeUrl)) {
272
- errors.push("configurations.readmeUrl cannot be hosted on github.com; use raw.githubusercontent.com or another domain");
273
- } else if (!skipUrlChecks) {
274
- const accessible = await isUrlAccessible(configs.readmeUrl);
275
- if (!accessible) {
276
- errors.push("configurations.readmeUrl is not accessible");
277
- }
278
- }
279
- }
280
-
281
- // CSP Exceptions validation (optional, but validated if present)
282
- if (packageJson.cspExceptions) {
283
- const cspExceptions = packageJson.cspExceptions;
284
-
285
- if (typeof cspExceptions !== "object" || cspExceptions === null || Array.isArray(cspExceptions)) {
286
- errors.push("cspExceptions must be an object mapping CSP directives to arrays of strings");
287
- } else {
288
- const validCspDirectives = ["connect-src", "script-src", "style-src", "img-src", "font-src", "frame-src", "media-src"];
289
-
290
- const hasAnyDirectives = Object.keys(cspExceptions).length > 0;
291
- if (!hasAnyDirectives) {
292
- errors.push("cspExceptions cannot be empty. If CSP exceptions are not needed, remove the cspExceptions field");
293
- }
294
-
295
- Object.keys(cspExceptions).forEach((directive) => {
296
- if (!validCspDirectives.includes(directive)) {
297
- warnings.push(`Unknown CSP directive: ${directive}`);
298
- }
299
- const values = cspExceptions[/** @type {keyof CspExceptions} */ (directive)];
300
- if (values && !Array.isArray(values)) {
301
- errors.push(`CSP directive "${directive}" must be an array of strings`);
302
- } else if (values && values.length === 0) {
303
- errors.push(`CSP directive "${directive}" cannot be an empty array`);
304
- }
305
- });
306
- }
307
- }
308
-
309
- // Features validation (optional, but validated if present)
310
- if (packageJson.features !== undefined) {
311
- const features = packageJson.features;
312
-
313
- if (features === null || typeof features !== "object" || Array.isArray(features)) {
314
- errors.push("features must be a non-array object with optional 'multiConnection' and 'minAPI' properties");
315
- } else {
316
- const VALID_FEATURE_KEYS = ["multiConnection", "minAPI"];
317
- const featureKeys = Object.keys(features);
318
- const invalidKeys = featureKeys.filter((key) => !VALID_FEATURE_KEYS.includes(key));
319
-
320
- if (invalidKeys.length > 0) {
321
- errors.push(`features can only contain ${VALID_FEATURE_KEYS.map((k) => `'${k}'`).join(", ")} properties. Invalid properties: ${invalidKeys.join(", ")}`);
322
- }
323
-
324
- if (features.multiConnection === undefined) {
325
- errors.push("features.multiConnection is required when features object is provided");
326
- } else if (!VALID_MULTI_CONNECTION_VALUES.includes(features.multiConnection)) {
327
- errors.push(`features.multiConnection must be one of: ${VALID_MULTI_CONNECTION_VALUES.join(", ")}`);
328
- }
329
-
330
- if (features.minAPI !== undefined) {
331
- if (typeof features.minAPI !== "string" || !SEMVER_REGEX.test(features.minAPI)) {
332
- errors.push("features.minAPI must be a valid semantic version string (e.g., '1.0.0')");
333
- }
334
- }
335
- }
336
- }
337
-
338
- const valid = errors.length === 0;
339
-
340
- return {
341
- valid,
342
- errors,
343
- warnings,
344
- packageInfo: valid
345
- ? {
346
- name: packageJson.name,
347
- version: packageJson.version,
348
- displayName: packageJson.displayName,
349
- description: packageJson.description,
350
- license: packageJson.license,
351
- contributors: packageJson.contributors,
352
- cspExceptions: packageJson.cspExceptions,
353
- icon: packageJson.icon,
354
- configurations: packageJson.configurations,
355
- features: packageJson.features,
356
- }
357
- : undefined,
358
- };
359
- }
360
-
361
- module.exports = { validatePackageJson, validatePPTBConfig, isValidUrl, APPROVED_LICENSES, KNOWN_CAPABILITY_TAGS };
362
-
363
- /**
364
- * Validates a tool's pptb.config.json against the official review criteria.
365
- *
366
- * @param {PPTBConfig} config - The parsed pptb.config.json object.
367
- * @returns {ValidationResult}
368
- */
369
- function validatePPTBConfig(config) {
370
- const errors = /** @type {string[]} */ ([]);
371
- const warnings = /** @type {string[]} */ ([]);
372
-
373
- if (config === null || typeof config !== "object" || Array.isArray(config)) {
374
- errors.push("pptb.config.json must be a JSON object");
375
- return { valid: false, errors, warnings };
376
- }
377
-
378
- const VALID_ROOT_KEYS = ["invocation"];
379
- const unknownRootKeys = Object.keys(config).filter((k) => !VALID_ROOT_KEYS.includes(k));
380
- if (unknownRootKeys.length > 0) {
381
- warnings.push(`pptb.config.json contains unrecognised root keys: ${unknownRootKeys.join(", ")}`);
382
- }
383
-
384
- // Invocation section
385
- if (config.invocation !== undefined) {
386
- const inv = config.invocation;
387
-
388
- if (inv === null || typeof inv !== "object" || Array.isArray(inv)) {
389
- errors.push("invocation must be a non-array object");
390
- } else {
391
- // invocation.version – required, must be a valid semver string
392
- if (inv.version === undefined || inv.version === null) {
393
- errors.push("invocation.version is required");
394
- } else if (typeof inv.version !== "string") {
395
- errors.push("invocation.version must be a string");
396
- } else if (!SEMVER_REGEX.test(inv.version)) {
397
- errors.push(`invocation.version "${inv.version}" is not a valid semantic version string (e.g. "1.0.0")`);
398
- }
399
-
400
- // invocation.prefill – optional JSON-schema-like object
401
- if (inv.prefill !== undefined) {
402
- if (inv.prefill === null || typeof inv.prefill !== "object" || Array.isArray(inv.prefill)) {
403
- errors.push("invocation.prefill must be a non-array object");
404
- } else if (inv.prefill.properties !== undefined) {
405
- validateJsonSchemaProperties("invocation.prefill", inv.prefill.properties, errors);
406
- }
407
- }
408
-
409
- // invocation.returnTopic – optional JSON-schema-like object
410
- if (inv.returnTopic !== undefined) {
411
- if (inv.returnTopic === null || typeof inv.returnTopic !== "object" || Array.isArray(inv.returnTopic)) {
412
- errors.push("invocation.returnTopic must be a non-array object");
413
- } else if (inv.returnTopic.properties !== undefined) {
414
- validateJsonSchemaProperties("invocation.returnTopic", inv.returnTopic.properties, errors);
415
- }
416
- }
417
-
418
- // invocation.capabilities – optional array of non-empty strings
419
- if (inv.capabilities !== undefined) {
420
- if (!Array.isArray(inv.capabilities)) {
421
- errors.push("invocation.capabilities must be an array");
422
- } else {
423
- inv.capabilities.forEach((cap, idx) => {
424
- if (typeof cap !== "string" || cap.trim().length === 0) {
425
- errors.push(`invocation.capabilities[${idx}] must be a non-empty string`);
426
- } else if (!KNOWN_CAPABILITY_TAGS.includes(cap.trim())) {
427
- warnings.push(
428
- `invocation.capabilities[${idx}] "${cap}" is not a recognised capability tag. ` +
429
- `Known tags: ${KNOWN_CAPABILITY_TAGS.join(", ")}. ` +
430
- "If this is a new tag, ensure it has been added to the capability registry.",
431
- );
432
- }
433
- });
434
- }
435
- }
436
- }
437
- }
438
-
439
- const valid = errors.length === 0;
440
-
441
- return {
442
- valid,
443
- errors,
444
- warnings,
445
- packageInfo: valid
446
- ? {
447
- invocation: config.invocation,
448
- }
449
- : undefined,
450
- };
451
- }
452
-
453
- /**
454
- * Validates a JSON-schema-style `properties` map used inside invocation sections.
455
- * Only performs basic structural validation; full JSON Schema validation is not required.
456
- *
457
- * @param {string} fieldName
458
- * @param {unknown} properties
459
- * @param {string[]} errors
460
- */
461
- function validateJsonSchemaProperties(fieldName, properties, errors) {
462
- if (properties === null || typeof properties !== "object" || Array.isArray(properties)) {
463
- errors.push(`${fieldName}.properties must be a non-array object`);
464
- return;
465
- }
466
-
467
- const propsRecord = /** @type {Record<string, unknown>} */ (properties);
468
-
469
- for (const [key, value] of Object.entries(propsRecord)) {
470
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
471
- errors.push(`${fieldName}.properties.${key} must be an object`);
472
- continue;
473
- }
474
- const prop = /** @type {Record<string, unknown>} */ (value);
475
- if (prop.type !== undefined && typeof prop.type !== "string") {
476
- errors.push(`${fieldName}.properties.${key}.type must be a string`);
477
- }
478
- if (prop.enum !== undefined) {
479
- if (!Array.isArray(prop.enum)) {
480
- errors.push(`${fieldName}.properties.${key}.enum must be an array`);
481
- } else if (prop.enum.length === 0) {
482
- errors.push(`${fieldName}.properties.${key}.enum must not be empty`);
483
- }
484
- }
485
- }
486
- }