@zync-sh/plugin-sdk 2.0.0-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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zync.sh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # Zync plugin SDK (beta)
2
+
3
+ Public authoring types for Manifest v2 plugins. This package lives in the Zync repository but has its own npm version and release lifecycle.
4
+
5
+ Install the prerelease as a development dependency:
6
+
7
+ ```sh
8
+ npm install --save-dev @zync-sh/plugin-sdk@beta
9
+ ```
10
+
11
+ To try the local package from another project, install it by path:
12
+
13
+ ```sh
14
+ npm install --save-dev /path/to/zync/packages/plugin-sdk
15
+ ```
16
+
17
+ Zync supplies the `zync` object when it starts a plugin worker or pane. Do not bundle an SDK runtime into the plugin.
18
+
19
+ ## Manifest
20
+
21
+ `defineManifest` gives TypeScript a Manifest v2 contract and returns the same object. It does **not** validate a package or grant permissions; the Zync host is the final authority.
22
+
23
+ ```ts
24
+ import { defineManifest } from '@zync-sh/plugin-sdk';
25
+
26
+ export default defineManifest({
27
+ manifestVersion: 2,
28
+ id: 'com.example.hello',
29
+ name: 'Hello',
30
+ version: '1.0.0',
31
+ publisher: 'com.example',
32
+ engines: { zync: '>=2.32.2', pluginApi: '^2.0.0' },
33
+ runtime: { entry: 'worker.js' },
34
+ contributes: {
35
+ commands: [{ id: 'hello.say-hi', title: 'Say hi' }],
36
+ },
37
+ permissions: {
38
+ required: [{ id: 'ui.commands.register', reason: 'Add the command to Zync.' }],
39
+ },
40
+ });
41
+ ```
42
+
43
+ Write the resulting object to `manifest.json` during your build. Zync installs the JSON and the built plugin files, not the TypeScript source.
44
+
45
+ ## Validate before signing
46
+
47
+ Run the packaged CLI against the **built plugin directory** (the one containing `manifest.json` and its referenced assets):
48
+
49
+ ```sh
50
+ npx zync-plugin validate ./dist/my-plugin --zync-version 2.32.2
51
+ ```
52
+
53
+ In this repository, the same check is available as `npm run plugin:validate -- ./examples/plugins/manifest-v2-demo`. For programmatic checks, import `validateManifest` or `validatePackageDirectory` from `@zync-sh/plugin-sdk/validate`. Validation returns `{ valid, issues }`; warnings do not fail the check.
54
+
55
+ The preflight checks Manifest v2 fields, publisher namespace, semantic plugin version, contribution/permission declarations, known permissions, network host declarations, referenced files, basic package limits, and plugin API compatibility. Pass `--zync-version` to check compatibility with a specific app build; without it, the Zync range is syntax-checked only. Unknown optional permissions produce warnings because the host denies them until supported. The preflight does **not** validate signatures, inspect executable behavior, or replace native install-time validation. The signing tool and native host retain their own package and security checks.
56
+
57
+ The [basic starter template](templates/basic/README.md) is included in this package. It builds a minimal worker and isolated pane, then validates the output. See [RELEASE.md](RELEASE.md) for versioning and the publication checklist.
58
+
59
+ ## Host-provided APIs
60
+
61
+ For a worker:
62
+
63
+ ```ts
64
+ import type { ZyncWorkerApi } from '@zync-sh/plugin-sdk/worker';
65
+
66
+ declare const zync: ZyncWorkerApi;
67
+
68
+ zync.on('ready', async () => {
69
+ await zync.commands.register('hello.say-hi', 'Say hi', async () => {
70
+ await zync.ui.notify({ message: 'Hello from the plugin' });
71
+ });
72
+ });
73
+ ```
74
+
75
+ For an isolated pane, use `import type { ZyncPaneApi } from '@zync-sh/plugin-sdk/pane'` and declare `window.zync` with that type in your pane source. Panes can exchange messages with their worker; they do not get the worker API, host DOM, or direct network access.
76
+
77
+ The typed worker interface covers the Manifest v2 broker APIs only. Legacy plugin APIs are deliberately absent. Every host operation is still checked against the installed manifest, current grant, runtime identity, and applicable scope. The SDK version does not replace the manifest's `engines.pluginApi` compatibility declaration.
78
+
79
+ See the [plugin architecture](https://github.com/zync-sh/zync/blob/main/docs/PLUGINS.md) and [Manifest v2 demo](https://github.com/zync-sh/zync/tree/main/examples/plugins/manifest-v2-demo) for package format, permissions, signing, and manual testing.
package/RELEASE.md ADDED
@@ -0,0 +1,16 @@
1
+ # SDK release policy
2
+
3
+ The npm package version and the Zync plugin API version are separate. `@zync-sh/plugin-sdk` may release documentation, types, or tooling fixes without changing the host API. A breaking host API requires a new `engines.pluginApi` major version and matching native/SDK support. Plugin packages continue to declare their own `version`, `engines.zync`, and `engines.pluginApi` ranges.
4
+
5
+ Manifest v2 engine ranges use semantic-version comparators such as `^2.0.0`, `>=2.32.2`, or `>=2.32.2, <3.0.0`. Avoid npm-only unions (`||`) and hyphen ranges; the native host is authoritative. Pre-release host versions require a matching pre-release comparator. When a plugin's engine range does not match the running host, Zync rejects install/activation or skips loading the installed plugin. A rollback to an incompatible retained version is rejected before replacing the active package.
6
+
7
+ Before a beta npm SDK release:
8
+
9
+ 1. Run `npm run sdk:release-check` from the Zync repository root. It checks type contracts, validator cases, the starter build, and the exact npm package contents.
10
+ 2. Run the native plugin tests and the full agent regression suite.
11
+ 3. Run `node check.mjs` in the sibling `zync-plugin-channel-examples` project to validate, sign, and verify stable and beta builds in a disposable registry.
12
+ 4. Review the exact SDK tarball, production dependency audit, license, and documentation. Publish the prerelease with the `beta` npm tag, never `latest`, and install it in a clean project to verify the CLI and exported types.
13
+
14
+ The SDK beta is an authoring tool, not a production marketplace launch. Before promoting it to `latest` or calling the marketplace production-ready, deploy the signed test builds to a protected HTTPS staging registry. Manually verify marketplace listing, opt-in beta update, switch back to stable, permission review, and retained-version rollback in the desktop app. Record the tested Zync build, SDK version, registry version, and package digests; complete the external-plugin smoke test and independent security review. Local signing tests do not substitute for these checks.
15
+
16
+ The automatic checks are not a security audit. Do not publish the package merely because they pass.
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ import { validatePackageDirectory } from '../validate.js';
3
+
4
+ const [command, directory, ...extra] = process.argv.slice(2);
5
+ const withVersion = extra.length === 2 && extra[0] === '--zync-version';
6
+ if (command !== 'validate' || !directory || (extra.length && !withVersion)) {
7
+ console.error('Usage: zync-plugin validate <plugin-directory> [--zync-version <version>]');
8
+ process.exitCode = 2;
9
+ } else {
10
+ try {
11
+ const result = validatePackageDirectory(directory, withVersion ? { zyncVersion: extra[1] } : {});
12
+ for (const issue of result.issues) {
13
+ const stream = issue.severity === 'error' ? process.stderr : process.stdout;
14
+ stream.write(`${issue.severity}: ${issue.path}: ${issue.message}\n`);
15
+ }
16
+ if (result.valid) console.log('Plugin preflight passed. Zync will validate again at install time.');
17
+ else process.exitCode = 1;
18
+ } catch (error) {
19
+ console.error(error instanceof Error ? error.message : String(error));
20
+ process.exitCode = 1;
21
+ }
22
+ }
package/index.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ export interface PermissionRequest {
2
+ id: string;
3
+ reason: string;
4
+ scope?: string;
5
+ hosts?: string[];
6
+ }
7
+
8
+ export interface SurfaceContribution {
9
+ id: string;
10
+ title: string;
11
+ entry: string;
12
+ allowMultiple?: boolean;
13
+ }
14
+
15
+ export interface ManifestV2 {
16
+ manifestVersion: 2;
17
+ id: string;
18
+ name: string;
19
+ version: string;
20
+ publisher: string;
21
+ description?: string;
22
+ license?: string;
23
+ homepage?: string;
24
+ support?: string;
25
+ privacyPolicy?: string;
26
+ engines: { zync: string; pluginApi: string };
27
+ runtime?: { entry?: string };
28
+ contributes?: {
29
+ commands?: Array<{ id: string; title: string }>;
30
+ paneKinds?: SurfaceContribution[];
31
+ dashboardCards?: SurfaceContribution[];
32
+ };
33
+ permissions?: {
34
+ required?: PermissionRequest[];
35
+ optional?: PermissionRequest[];
36
+ };
37
+ icon?: string;
38
+ type?: string;
39
+ }
40
+
41
+ /** Type-checks the manifest; the native host remains the final validator. */
42
+ export function defineManifest<T extends ManifestV2>(manifest: T): T;
package/index.js ADDED
@@ -0,0 +1,4 @@
1
+ /** Returns a Manifest v2 object without changing it. Zync validates the package at install time. */
2
+ export function defineManifest(manifest) {
3
+ return manifest;
4
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@zync-sh/plugin-sdk",
3
+ "version": "2.0.0-beta.1",
4
+ "description": "Manifest v2 authoring types and preflight validation for Zync plugins",
5
+ "type": "module",
6
+ "dependencies": { "semver": "^7.0.0" },
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/zync-sh/zync.git",
11
+ "directory": "packages/plugin-sdk"
12
+ },
13
+ "engines": { "node": ">=20" },
14
+ "sideEffects": false,
15
+ "files": ["index.js", "index.d.ts", "worker.d.ts", "pane.d.ts", "validate.js", "validate.d.ts", "bin/zync-plugin.mjs", "templates/basic", "README.md", "RELEASE.md", "LICENSE"],
16
+ "exports": {
17
+ ".": { "types": "./index.d.ts", "default": "./index.js" },
18
+ "./validate": { "types": "./validate.d.ts", "default": "./validate.js" },
19
+ "./worker": { "types": "./worker.d.ts" },
20
+ "./pane": { "types": "./pane.d.ts" }
21
+ },
22
+ "bin": { "zync-plugin": "bin/zync-plugin.mjs" },
23
+ "types": "./index.d.ts",
24
+ "scripts": { "prepublishOnly": "node ../../scripts/plugin-signing/sdk-release-check.mjs" },
25
+ "publishConfig": { "access": "public" }
26
+ }
package/pane.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export interface ZyncPaneApi {
2
+ pane: {
3
+ postMessage(message: unknown): void;
4
+ onMessage(callback: (message: unknown) => void): () => void;
5
+ };
6
+ }
@@ -0,0 +1,21 @@
1
+ # Starter Zync plugin
2
+
3
+ Copy this directory into a new project and replace the sample publisher, id, and name in `manifest.mjs` before distributing it.
4
+
5
+ Install the prerelease SDK:
6
+
7
+ ```sh
8
+ npm install --save-dev @zync-sh/plugin-sdk@beta
9
+ ```
10
+
11
+ For an unreleased local checkout, install it by path instead:
12
+
13
+ ```sh
14
+ npm install --save-dev /path/to/zync/packages/plugin-sdk
15
+ ```
16
+
17
+ Then run `npm run validate`. That builds `dist/` and checks the manifest, referenced files, permissions, and package limits. You can pass the target app version directly with `npx zync-plugin validate dist --zync-version 2.32.2`.
18
+
19
+ To test locally, enable Developer Mode in Zync and install the `dist/` folder. Its pane should answer **Ask the Worker**. The pane cannot access the Worker API directly; messages go through Zync's bounded pane channel.
20
+
21
+ To sign a release, keep your publisher key outside the project and pass the validated `dist/` folder to Zync's signing tool. Do not publish the SDK, source files, keys, or `node_modules` as part of the plugin package.
@@ -0,0 +1,18 @@
1
+ import { defineManifest } from '@zync-sh/plugin-sdk';
2
+
3
+ export default defineManifest({
4
+ manifestVersion: 2,
5
+ id: 'dev.example.starter',
6
+ name: 'Starter Pane',
7
+ version: '1.0.0',
8
+ publisher: 'dev.example',
9
+ description: 'A minimal isolated Zync pane.',
10
+ engines: { zync: '>=2.32.2', pluginApi: '^2.0.0' },
11
+ runtime: { entry: 'worker.js' },
12
+ contributes: {
13
+ paneKinds: [{ id: 'starter.main', title: 'Starter Pane', entry: 'ui/index.html', allowMultiple: true }],
14
+ },
15
+ permissions: {
16
+ required: [{ id: 'ui.pane.register', reason: 'Add the starter pane to the workspace.' }],
17
+ },
18
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "zync-plugin-starter",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "build": "node scripts/build.mjs",
8
+ "validate": "npm run build && zync-plugin validate dist"
9
+ }
10
+ }
@@ -0,0 +1,15 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import manifest from '../manifest.mjs';
5
+
6
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7
+ const output = path.resolve(process.argv[2] ?? path.join(root, 'dist'));
8
+ if (output === root || !output.startsWith(`${root}${path.sep}`)) {
9
+ throw new Error('Build output must be a directory inside the plugin project');
10
+ }
11
+ fs.mkdirSync(path.join(output, 'ui'), { recursive: true });
12
+ fs.copyFileSync(path.join(root, 'src', 'worker.js'), path.join(output, 'worker.js'));
13
+ fs.copyFileSync(path.join(root, 'src', 'ui', 'index.html'), path.join(output, 'ui', 'index.html'));
14
+ fs.writeFileSync(path.join(output, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
15
+ console.log(`Built plugin: ${output}`);
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head><meta charset="utf-8"><title>Starter Pane</title></head>
4
+ <body>
5
+ <h1>Starter Pane</h1>
6
+ <button id="ping" type="button">Ask the Worker</button>
7
+ <p id="reply" aria-live="polite">Ready</p>
8
+ <script>
9
+ document.getElementById('ping').addEventListener('click', () => {
10
+ window.zync.pane.postMessage({ type: 'ping' });
11
+ });
12
+ window.zync.pane.onMessage(message => {
13
+ if (message?.type === 'pong') document.getElementById('reply').textContent = message.text;
14
+ });
15
+ </script>
16
+ </body>
17
+ </html>
@@ -0,0 +1,11 @@
1
+ // Zync supplies this object in the isolated plugin Worker.
2
+ const zync = globalThis.zync;
3
+
4
+ zync.on('ready', async () => {
5
+ await zync.panel.register('starter.main');
6
+ });
7
+
8
+ zync.panel.onMessage(({ paneInstanceId, message }) => {
9
+ if (message?.type !== 'ping') return;
10
+ void zync.panel.postMessage(paneInstanceId, { type: 'pong', text: 'Hello from the Worker' });
11
+ });
package/validate.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ export interface ValidationIssue {
2
+ path: string;
3
+ message: string;
4
+ severity: 'error' | 'warning';
5
+ }
6
+
7
+ export interface ValidationResult {
8
+ valid: boolean;
9
+ issues: ValidationIssue[];
10
+ }
11
+
12
+ export declare const knownPermissionIds: readonly string[];
13
+ export declare const pluginApiVersion: string;
14
+
15
+ export interface CompatibilityTarget {
16
+ zyncVersion?: string;
17
+ pluginApiVersion?: string;
18
+ }
19
+
20
+ /** Authoring preflight. Native install validation remains authoritative. */
21
+ export function validateManifest(manifest: unknown, target?: CompatibilityTarget): ValidationResult;
22
+
23
+ /** Checks manifest.json, referenced assets, and basic package limits without modifying the directory. */
24
+ export function validatePackageDirectory(directory: string, target?: CompatibilityTarget): ValidationResult;
package/validate.js ADDED
@@ -0,0 +1,327 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { isIP } from 'node:net';
4
+ import semver from 'semver';
5
+
6
+ // This preflight mirrors author-facing manifest rules. Installation still relies on
7
+ // the native validator, which also enforces package and runtime security boundaries.
8
+ export const knownPermissionIds = Object.freeze([
9
+ 'ui.pane.register', 'ui.dashboard.register', 'ui.commands.register',
10
+ 'ui.status.register', 'ui.notifications.emit', 'ui.sidebar.register',
11
+ 'ui.settings.register', 'editor.provider.register', 'theme.pack.register',
12
+ 'connection.metadata.read', 'terminal.input.send', 'terminal.tab.create',
13
+ 'terminal.command.execute', 'ssh.command.execute',
14
+ 'filesystem.pluginData.read', 'filesystem.pluginData.write',
15
+ 'filesystem.external.read', 'filesystem.external.write', 'ssh.filesystem.read',
16
+ 'network.fetch', 'network.local', 'ui.dialog.confirm', 'clipboard.read',
17
+ 'clipboard.write', 'plugins.metadata.read',
18
+ ]);
19
+
20
+ const knownPermissions = new Set(knownPermissionIds);
21
+ export const pluginApiVersion = '2.0.0';
22
+ const identifierPattern = /^[A-Za-z0-9_.-]+$/;
23
+ const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
24
+ const contributionPermissions = {
25
+ commands: 'ui.commands.register',
26
+ paneKinds: 'ui.pane.register',
27
+ dashboardCards: 'ui.dashboard.register',
28
+ };
29
+ const contributionLimits = { commands: 128, paneKinds: 64, dashboardCards: 64 };
30
+
31
+ function record(issues, location, message, severity = 'error') {
32
+ issues.push({ path: location, message, severity });
33
+ }
34
+
35
+ function object(value) {
36
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
37
+ }
38
+
39
+ function text(issues, value, location, maxLength, required = true) {
40
+ if (value === undefined && !required) return false;
41
+ if (typeof value !== 'string' || !value.trim() || [...value].length > maxLength || /[\u0000-\u001f\u007f]/.test(value)) {
42
+ record(issues, location, `Must be non-empty text of at most ${maxLength} characters without controls`);
43
+ return false;
44
+ }
45
+ return true;
46
+ }
47
+
48
+ function identifier(issues, value, location) {
49
+ if (!text(issues, value, location, 128)) return false;
50
+ if (!identifierPattern.test(value) || value.startsWith('.') || value.endsWith('.') || value.includes('..')) {
51
+ record(issues, location, 'Must be an identifier using letters, digits, dot, dash, or underscore');
52
+ return false;
53
+ }
54
+ return true;
55
+ }
56
+
57
+ function assetPath(issues, value, location, required = false) {
58
+ if (value === undefined && !required) return false;
59
+ if (!text(issues, value, location, 260)) return false;
60
+ if (value.startsWith('/') || value.includes('\\') || value.includes(':') || value.split('/').some(part => !part || part === '.' || part === '..')) {
61
+ record(issues, location, 'Must be a relative path inside the plugin package');
62
+ return false;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ function httpsUrl(issues, value, location) {
68
+ if (value === undefined || !text(issues, value, location, 2048)) return;
69
+ try {
70
+ const url = new URL(value);
71
+ if (url.protocol === 'https:' && url.hostname && !url.username && !url.password) return;
72
+ } catch { /* Report one actionable error below. */ }
73
+ record(issues, location, 'Must be an HTTPS URL without embedded credentials');
74
+ }
75
+
76
+ function permissionHost(issues, host, location) {
77
+ if (typeof host !== 'string' || !host || /[/:@?#\\\s]/.test(host)) {
78
+ record(issues, location, 'Must be a domain name without a scheme, path, port, or credentials');
79
+ return;
80
+ }
81
+ if (host.includes('*') && !host.startsWith('*.')) {
82
+ record(issues, location, 'A wildcard may appear only as a leading *.');
83
+ return;
84
+ }
85
+ const domain = host.startsWith('*.') ? host.slice(2) : host;
86
+ if (!domain || domain.includes('*') || isIP(domain) || domain.split('.').some(part => !part)) {
87
+ record(issues, location, 'Must be a domain name, not an IP address');
88
+ return;
89
+ }
90
+ try {
91
+ if (new URL(`https://${domain}`).hostname) return;
92
+ } catch { /* Report one actionable error below. */ }
93
+ record(issues, location, 'Must be a valid domain name');
94
+ }
95
+
96
+ function validatePermissions(issues, value) {
97
+ if (value === undefined) return new Set();
98
+ if (!object(value)) {
99
+ record(issues, 'permissions', 'Must be an object');
100
+ return new Set();
101
+ }
102
+ const declared = new Set();
103
+ let count = 0;
104
+ for (const group of ['required', 'optional']) {
105
+ const entries = value[group] ?? [];
106
+ if (!Array.isArray(entries)) {
107
+ record(issues, `permissions.${group}`, 'Must be an array');
108
+ continue;
109
+ }
110
+ count += entries.length;
111
+ entries.forEach((entry, index) => {
112
+ const location = `permissions.${group}[${index}]`;
113
+ if (!object(entry)) {
114
+ record(issues, location, 'Must be an object');
115
+ return;
116
+ }
117
+ if (identifier(issues, entry.id, `${location}.id`)) {
118
+ if (declared.has(entry.id)) record(issues, `${location}.id`, 'Permission is declared more than once');
119
+ declared.add(entry.id);
120
+ if (!knownPermissions.has(entry.id)) {
121
+ record(issues, `${location}.id`, group === 'required'
122
+ ? 'Unknown required permission'
123
+ : 'Unknown optional permission; Zync will deny it until supported', group === 'required' ? 'error' : 'warning');
124
+ }
125
+ }
126
+ text(issues, entry.reason, `${location}.reason`, 240);
127
+ text(issues, entry.scope, `${location}.scope`, 80, false);
128
+ const hosts = entry.hosts ?? [];
129
+ if (!Array.isArray(hosts)) {
130
+ record(issues, `${location}.hosts`, 'Must be an array');
131
+ return;
132
+ }
133
+ if (hosts.length > 32) record(issues, `${location}.hosts`, 'May contain at most 32 hosts');
134
+ if (hosts.length && !['network.fetch', 'network.local'].includes(entry.id)) {
135
+ record(issues, `${location}.hosts`, 'Only network permissions may declare hosts');
136
+ }
137
+ if (entry.id === 'network.fetch' && !hosts.length) {
138
+ record(issues, `${location}.hosts`, 'network.fetch requires at least one host');
139
+ }
140
+ const seen = new Set();
141
+ hosts.forEach((host, hostIndex) => {
142
+ permissionHost(issues, host, `${location}.hosts[${hostIndex}]`);
143
+ if (typeof host === 'string') {
144
+ const key = host.toLowerCase();
145
+ if (seen.has(key)) record(issues, `${location}.hosts[${hostIndex}]`, 'Host is declared more than once');
146
+ seen.add(key);
147
+ }
148
+ });
149
+ });
150
+ }
151
+ if (count > 64) record(issues, 'permissions', 'May declare at most 64 permissions');
152
+ return declared;
153
+ }
154
+
155
+ function validateContributions(issues, value, declared, assets) {
156
+ if (value === undefined) return;
157
+ if (!object(value)) {
158
+ record(issues, 'contributes', 'Must be an object');
159
+ return;
160
+ }
161
+ for (const key of Object.keys(value)) {
162
+ if (!(key in contributionLimits)) record(issues, `contributes.${key}`, 'Unknown contribution kind');
163
+ }
164
+ for (const [kind, limit] of Object.entries(contributionLimits)) {
165
+ const entries = value[kind] ?? [];
166
+ if (!Array.isArray(entries)) {
167
+ record(issues, `contributes.${kind}`, 'Must be an array');
168
+ continue;
169
+ }
170
+ if (entries.length > limit) record(issues, `contributes.${kind}`, `May contain at most ${limit} entries`);
171
+ if (entries.length && !declared.has(contributionPermissions[kind])) {
172
+ record(issues, `contributes.${kind}`, `Requires ${contributionPermissions[kind]} permission`);
173
+ }
174
+ const seen = new Set();
175
+ entries.forEach((entry, index) => {
176
+ const location = `contributes.${kind}[${index}]`;
177
+ if (!object(entry)) {
178
+ record(issues, location, 'Must be an object');
179
+ return;
180
+ }
181
+ if (identifier(issues, entry.id, `${location}.id`)) {
182
+ if (seen.has(entry.id)) record(issues, `${location}.id`, 'Contribution id is declared more than once');
183
+ seen.add(entry.id);
184
+ }
185
+ text(issues, entry.title, `${location}.title`, 120);
186
+ if (kind !== 'commands') {
187
+ if (assetPath(issues, entry.entry, `${location}.entry`, true)) assets.push({ path: entry.entry, location: `${location}.entry` });
188
+ if (entry.allowMultiple !== undefined && typeof entry.allowMultiple !== 'boolean') {
189
+ record(issues, `${location}.allowMultiple`, 'Must be a boolean');
190
+ }
191
+ }
192
+ });
193
+ }
194
+ }
195
+
196
+ function engineRange(issues, range, location, installedVersion) {
197
+ if (!text(issues, range, location, 80)) return;
198
+ // Rust's host-side VersionReq does not support npm's union and hyphen syntax.
199
+ if (range.includes('||') || /\s-\s/.test(range) || !semver.validRange(range)) {
200
+ record(issues, location, 'Must be a supported semantic version range');
201
+ return;
202
+ }
203
+ if (installedVersion !== undefined) {
204
+ if (!semver.valid(installedVersion)) {
205
+ record(issues, location, `Target version is invalid: ${installedVersion}`);
206
+ } else if (!semver.satisfies(installedVersion, range)) {
207
+ record(issues, location, `Requires ${range}, but the target provides ${installedVersion}`);
208
+ }
209
+ }
210
+ }
211
+
212
+ export function validateManifest(manifest, options = {}) {
213
+ const issues = [];
214
+ const assets = [];
215
+ if (!object(manifest)) {
216
+ record(issues, 'manifest', 'Must be a JSON object');
217
+ return { valid: false, issues, assets };
218
+ }
219
+ if (manifest.manifestVersion !== 2) record(issues, 'manifestVersion', 'Must be 2');
220
+ const validId = identifier(issues, manifest.id, 'id');
221
+ text(issues, manifest.name, 'name', 80);
222
+ const versionMatch = text(issues, manifest.version, 'version', 64) && semverPattern.exec(manifest.version);
223
+ if (versionMatch && versionMatch[4]?.split('.').some(part => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0'))) {
224
+ record(issues, 'version', 'Numeric prerelease identifiers must not have leading zeroes');
225
+ } else if (typeof manifest.version === 'string' && !versionMatch) {
226
+ record(issues, 'version', 'Must be a semantic version such as 1.0.0 or 1.0.0-beta.1');
227
+ }
228
+ const validPublisher = identifier(issues, manifest.publisher, 'publisher');
229
+ if (validId && validPublisher && !manifest.id.startsWith(`${manifest.publisher}.`)) {
230
+ record(issues, 'id', 'Must be namespaced to the publisher');
231
+ }
232
+ if (!object(manifest.engines)) {
233
+ record(issues, 'engines', 'Must declare zync and pluginApi version ranges');
234
+ } else {
235
+ engineRange(issues, manifest.engines.zync, 'engines.zync', options.zyncVersion);
236
+ engineRange(issues, manifest.engines.pluginApi, 'engines.pluginApi', options.pluginApiVersion ?? pluginApiVersion);
237
+ }
238
+ text(issues, manifest.description, 'description', 500, false);
239
+ text(issues, manifest.license, 'license', 80, false);
240
+ for (const field of ['homepage', 'support', 'privacyPolicy']) httpsUrl(issues, manifest[field], field);
241
+ if (manifest.runtime !== undefined && !object(manifest.runtime)) {
242
+ record(issues, 'runtime', 'Must be an object');
243
+ } else if (manifest.runtime && assetPath(issues, manifest.runtime.entry, 'runtime.entry')) {
244
+ assets.push({ path: manifest.runtime.entry, location: 'runtime.entry' });
245
+ }
246
+ for (const field of ['main', 'style', 'icon']) {
247
+ if (assetPath(issues, manifest[field], field)) assets.push({ path: manifest[field], location: field });
248
+ }
249
+ const declared = validatePermissions(issues, manifest.permissions);
250
+ validateContributions(issues, manifest.contributes, declared, assets);
251
+ return { valid: !issues.some(issue => issue.severity === 'error'), issues, assets };
252
+ }
253
+
254
+ export function validatePackageDirectory(directory, options = {}) {
255
+ const root = path.resolve(directory);
256
+ const issues = [];
257
+ let rootStat;
258
+ try { rootStat = fs.lstatSync(root); } catch { /* Report below. */ }
259
+ if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
260
+ record(issues, 'package', 'Must be an existing directory, not a link');
261
+ return { valid: false, issues };
262
+ }
263
+ const manifestPath = path.join(root, 'manifest.json');
264
+ let manifestStat;
265
+ try { manifestStat = fs.lstatSync(manifestPath); } catch { /* Report below. */ }
266
+ if (!manifestStat?.isFile() || manifestStat.isSymbolicLink()) {
267
+ record(issues, 'manifest.json', 'Must be a regular file');
268
+ return { valid: false, issues };
269
+ }
270
+ if (manifestStat.size > 256 * 1024) {
271
+ record(issues, 'manifest.json', 'Exceeds the native 256 KiB manifest limit');
272
+ return { valid: false, issues };
273
+ }
274
+ let manifest;
275
+ try { manifest = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(manifestPath))); }
276
+ catch (error) {
277
+ record(issues, 'manifest.json', `Invalid JSON: ${error.message}`);
278
+ return { valid: false, issues };
279
+ }
280
+ const result = validateManifest(manifest, options);
281
+ issues.push(...result.issues);
282
+ for (const asset of result.assets) {
283
+ let current = root;
284
+ let valid = true;
285
+ for (const segment of asset.path.split('/')) {
286
+ current = path.join(current, segment);
287
+ let stat;
288
+ try { stat = fs.lstatSync(current); } catch { /* Report below. */ }
289
+ if (!stat || stat.isSymbolicLink()) {
290
+ record(issues, asset.location, `Referenced file is missing or linked: ${asset.path}`);
291
+ valid = false;
292
+ break;
293
+ }
294
+ }
295
+ if (valid && !fs.lstatSync(current).isFile()) record(issues, asset.location, `Referenced path is not a file: ${asset.path}`);
296
+ }
297
+ const pending = [root];
298
+ let entries = 0;
299
+ let totalBytes = 0;
300
+ while (pending.length) {
301
+ const parent = pending.pop();
302
+ for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
303
+ const absolute = path.join(parent, entry.name);
304
+ const relative = path.relative(root, absolute).split(path.sep).join('/');
305
+ const stat = fs.lstatSync(absolute);
306
+ entries += 1;
307
+ if (entries > 2_048) {
308
+ record(issues, 'package', 'Contains more than 2,048 entries');
309
+ pending.length = 0;
310
+ break;
311
+ }
312
+ if (Buffer.byteLength(relative) > 512 || relative.split('/').some(part => part.includes(':') || /[\u0000-\u001f\u007f]/.test(part))) {
313
+ record(issues, relative, 'Invalid package path');
314
+ }
315
+ if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) {
316
+ record(issues, relative, 'Package entries must be regular files or directories, not links');
317
+ } else if (stat.isDirectory()) {
318
+ pending.push(absolute);
319
+ } else {
320
+ if (stat.size > 20 * 1024 * 1024) record(issues, relative, 'File exceeds the native 20 MiB limit');
321
+ totalBytes += stat.size;
322
+ }
323
+ }
324
+ }
325
+ if (totalBytes > 100 * 1024 * 1024) record(issues, 'package', 'Exceeds the native 100 MiB expanded-size limit');
326
+ return { valid: !issues.some(issue => issue.severity === 'error'), issues };
327
+ }
package/worker.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ export interface PluginFileHandle {
2
+ handle: string;
3
+ kind: 'file' | 'directory';
4
+ access: 'read' | 'write';
5
+ name: string;
6
+ }
7
+
8
+ export interface PluginFileEntry {
9
+ name: string;
10
+ kind: 'file' | 'directory' | 'unavailable';
11
+ size?: number;
12
+ }
13
+
14
+ export interface PluginNetworkResponse {
15
+ status: number;
16
+ finalUrl: string;
17
+ contentType?: string;
18
+ body: string;
19
+ bodyEncoding: 'utf8' | 'base64';
20
+ }
21
+
22
+ export interface PluginNotification {
23
+ type?: 'info' | 'success' | 'warning' | 'error';
24
+ title?: string;
25
+ message?: string;
26
+ body?: string;
27
+ duration?: number;
28
+ persist?: boolean;
29
+ silent?: boolean;
30
+ history?: boolean;
31
+ channel?: 'auto' | 'toast' | 'inbox' | 'both';
32
+ id?: string;
33
+ actions?: Array<{ id: string; label: string; dismiss?: boolean }>;
34
+ }
35
+
36
+ export interface ZyncWorkerApi {
37
+ on(event: 'ready', callback: () => void | Promise<void>): () => void;
38
+ ui: {
39
+ notify(options: PluginNotification): Promise<{ ok: true }>;
40
+ confirm(options: { title: string; message: string; confirmLabel?: string; cancelLabel?: string }): Promise<boolean>;
41
+ onNotifyAction(callback: (event: { actionId: string; notificationId?: string }) => void | Promise<unknown>): () => void;
42
+ };
43
+ commands: {
44
+ register(id: string, title: string, handler: () => void | Promise<void>): Promise<{ ok: boolean }>;
45
+ };
46
+ panel: {
47
+ register(id: string): Promise<{ id: string; title: string }>;
48
+ onMessage(callback: (event: { paneInstanceId: string; message: unknown }) => void): () => void;
49
+ postMessage(paneInstanceId: string, message: unknown): Promise<boolean>;
50
+ };
51
+ storage: {
52
+ get(key: string): Promise<string | null>;
53
+ keys(): Promise<string[]>;
54
+ set(key: string, value: string): Promise<void>;
55
+ delete(key: string): Promise<boolean>;
56
+ };
57
+ network: {
58
+ fetch(url: string, options?: { accept?: string }): Promise<PluginNetworkResponse>;
59
+ };
60
+ filesystem: {
61
+ pickFile(): Promise<PluginFileHandle | null>;
62
+ pickDirectory(): Promise<PluginFileHandle | null>;
63
+ pickWriteFile(): Promise<PluginFileHandle | null>;
64
+ readText(handle: string, relativePath?: string): Promise<string>;
65
+ writeText(handle: string, content: string): Promise<void>;
66
+ list(handle: string, relativePath?: string): Promise<PluginFileEntry[]>;
67
+ };
68
+ sshFilesystem: {
69
+ list(paneInstanceId: string, relativePath?: string): Promise<PluginFileEntry[]>;
70
+ readText(paneInstanceId: string, relativePath: string): Promise<string>;
71
+ };
72
+ }