[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
31
packages/plugin-cli/package.json
Normal file
31
packages/plugin-cli/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@netcatty/plugin-cli",
|
||||
"version": "0.1.0-internal",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"files": ["dist"],
|
||||
"bin": {
|
||||
"netcatty-plugin": "./dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@netcatty/plugin-contract": "0.1.0-internal",
|
||||
"ajv": "8.18.0",
|
||||
"ajv-formats": "3.0.1",
|
||||
"semver": "^7.7.3",
|
||||
"yauzl": "^3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/yauzl": "^2.10.3"
|
||||
}
|
||||
}
|
||||
796
packages/plugin-cli/src/archive.ts
Normal file
796
packages/plugin-cli/src/archive.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { constants, createReadStream, createWriteStream } from "node:fs";
|
||||
import {
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
open,
|
||||
opendir,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
stat,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { once } from "node:events";
|
||||
|
||||
import type { IconReference, PluginManifest } from "@netcatty/plugin-contract";
|
||||
import yauzl, { type Entry, type ZipFile } from "yauzl";
|
||||
|
||||
import { IGNORED_ROOT_ENTRIES, PACKAGE_LIMITS } from "./constants.js";
|
||||
import {
|
||||
parseAndValidateManifestContents,
|
||||
readValidatedManifestSource,
|
||||
type ValidatedManifestSource,
|
||||
} from "./manifest.js";
|
||||
import { assertSafePackagePath, PackagePathRegistry } from "./packagePath.js";
|
||||
|
||||
const CRC32_TABLE = new Uint32Array(256);
|
||||
const EXECUTABLE_EXTENSIONS = new Set([".bat", ".cmd", ".com", ".exe", ".ps1"]);
|
||||
for (let index = 0; index < CRC32_TABLE.length; index += 1) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = (value & 1) === 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
}
|
||||
CRC32_TABLE[index] = value >>> 0;
|
||||
}
|
||||
|
||||
interface ScannedFile {
|
||||
readonly absolutePath: string;
|
||||
readonly packagePath: string;
|
||||
readonly size: number;
|
||||
readonly crc32: number;
|
||||
readonly sha256: string;
|
||||
readonly executable: boolean;
|
||||
}
|
||||
|
||||
interface ScannedManifestIdentity {
|
||||
readonly size: number;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
export function assertManifestSnapshotMatches(
|
||||
source: Pick<ValidatedManifestSource, "size" | "sha256">,
|
||||
scanned: ScannedManifestIdentity | undefined,
|
||||
): void {
|
||||
if (!scanned || scanned.size !== source.size || scanned.sha256 !== source.sha256) {
|
||||
throw new Error("Plugin manifest changed after validation");
|
||||
}
|
||||
}
|
||||
|
||||
export interface PackageBuildResult {
|
||||
readonly outputPath: string;
|
||||
readonly fileCount: number;
|
||||
readonly uncompressedBytes: number;
|
||||
readonly archiveBytes: number;
|
||||
readonly sha256: string;
|
||||
readonly contentSha256: string;
|
||||
}
|
||||
|
||||
export interface PackageValidationResult {
|
||||
readonly manifest: PluginManifest;
|
||||
readonly fileCount: number;
|
||||
readonly uncompressedBytes: number;
|
||||
readonly contentSha256: string;
|
||||
}
|
||||
|
||||
export type PluginDirectoryValidationResult = PackageValidationResult;
|
||||
|
||||
function updateCrc32(current: number, chunk: Buffer): number {
|
||||
let crc = current;
|
||||
for (const byte of chunk) crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
return crc >>> 0;
|
||||
}
|
||||
|
||||
async function readRegularFile(
|
||||
filePath: string,
|
||||
maxBytes: number,
|
||||
onChunk: (chunk: Buffer) => void,
|
||||
): Promise<{ size: number; mode: number }> {
|
||||
const noFollow = "O_NOFOLLOW" in constants
|
||||
? constants.O_NOFOLLOW
|
||||
: 0;
|
||||
const handle = await open(filePath, constants.O_RDONLY | noFollow);
|
||||
try {
|
||||
const fileStats = await handle.stat();
|
||||
if (!fileStats.isFile()) throw new Error(`Package source is not a regular file: ${filePath}`);
|
||||
let size = 0;
|
||||
const stream = createReadStream(filePath, { fd: handle.fd, autoClose: false });
|
||||
for await (const chunk of stream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
size += buffer.byteLength;
|
||||
if (size > maxBytes) {
|
||||
throw new Error(`Package source exceeds ${maxBytes} bytes while reading: ${filePath}`);
|
||||
}
|
||||
onChunk(buffer);
|
||||
}
|
||||
return { size, mode: fileStats.mode };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function hashFile(
|
||||
filePath: string,
|
||||
maxBytes: number,
|
||||
): Promise<{ crc32: number; sha256: string; size: number; mode: number }> {
|
||||
const sha256 = createHash("sha256");
|
||||
let crc = 0xffffffff;
|
||||
const file = await readRegularFile(filePath, maxBytes, (buffer) => {
|
||||
sha256.update(buffer);
|
||||
crc = updateCrc32(crc, buffer);
|
||||
});
|
||||
return {
|
||||
crc32: (crc ^ 0xffffffff) >>> 0,
|
||||
sha256: sha256.digest("hex"),
|
||||
size: file.size,
|
||||
mode: file.mode,
|
||||
};
|
||||
}
|
||||
|
||||
function sortPackagePaths(left: ScannedFile, right: ScannedFile): number {
|
||||
return Buffer.compare(Buffer.from(left.packagePath), Buffer.from(right.packagePath));
|
||||
}
|
||||
|
||||
interface PackageContentIdentity {
|
||||
readonly packagePath: string;
|
||||
readonly size: number;
|
||||
readonly sha256: string;
|
||||
readonly executable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash the logical package contents instead of the ZIP representation. This
|
||||
* keeps integrity checks stable across valid ZIP encoders and compression
|
||||
* choices while binding every path, byte length, executable bit, and file
|
||||
* digest into one versioned identity.
|
||||
*/
|
||||
export function computePackageContentSha256(
|
||||
entries: readonly PackageContentIdentity[],
|
||||
): string {
|
||||
const hash = createHash("sha256");
|
||||
hash.update("netcatty-plugin-content-v1\0", "utf8");
|
||||
const ordered = [...entries].sort((left, right) => (
|
||||
Buffer.compare(Buffer.from(left.packagePath), Buffer.from(right.packagePath))
|
||||
));
|
||||
for (const entry of ordered) {
|
||||
const packagePath = Buffer.from(entry.packagePath, "utf8");
|
||||
const header = Buffer.allocUnsafe(13);
|
||||
header.writeUInt32BE(packagePath.byteLength, 0);
|
||||
header.writeBigUInt64BE(BigInt(entry.size), 4);
|
||||
header.writeUInt8(entry.executable ? 1 : 0, 12);
|
||||
hash.update(header);
|
||||
hash.update(packagePath);
|
||||
hash.update(Buffer.from(entry.sha256, "hex"));
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function isExecutablePackageFile(packagePath: string, mode: number): boolean {
|
||||
return (mode & 0o111) !== 0
|
||||
|| EXECUTABLE_EXTENSIONS.has(path.posix.extname(packagePath).toLowerCase());
|
||||
}
|
||||
|
||||
function packageIconPaths(icon: IconReference | undefined): string[] {
|
||||
if (icon?.kind !== "package") return [];
|
||||
return icon.dark
|
||||
? [icon.light, icon.dark]
|
||||
: [icon.light];
|
||||
}
|
||||
|
||||
function isSameOrDescendantPath(parentPath: string, candidatePath: string): boolean {
|
||||
const relativePath = path.relative(parentPath, candidatePath);
|
||||
return relativePath === ""
|
||||
|| (!path.isAbsolute(relativePath)
|
||||
&& relativePath !== ".."
|
||||
&& !relativePath.startsWith(`..${path.sep}`));
|
||||
}
|
||||
|
||||
async function resolveThroughExistingAncestor(targetPath: string): Promise<string> {
|
||||
let currentPath = targetPath;
|
||||
const missingSegments: string[] = [];
|
||||
while (true) {
|
||||
try {
|
||||
const resolvedAncestor = await realpath(currentPath);
|
||||
return path.join(resolvedAncestor, ...missingSegments.reverse());
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
||||
const parentPath = path.dirname(currentPath);
|
||||
if (parentPath === currentPath) throw error;
|
||||
missingSegments.push(path.basename(currentPath));
|
||||
currentPath = parentPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function scanPackageDirectory(
|
||||
pluginDirectory: string,
|
||||
manifestSource: ValidatedManifestSource,
|
||||
options: { allowIgnoredRootEntries?: boolean } = {},
|
||||
): Promise<ScannedFile[]> {
|
||||
const { manifest } = manifestSource;
|
||||
const registry = new PackagePathRegistry();
|
||||
const companionPaths = new Map(
|
||||
(manifest.companionExecutables ?? []).flatMap((companion) => (
|
||||
companion.variants.map((variant) => [variant.path, variant] as const)
|
||||
)),
|
||||
);
|
||||
const files: ScannedFile[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
async function visit(directory: string, relativeDirectory: string): Promise<void> {
|
||||
const entries = [];
|
||||
for await (const entry of await opendir(directory)) entries.push(entry);
|
||||
entries.sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)));
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
relativeDirectory === ""
|
||||
&& (IGNORED_ROOT_ENTRIES.has(entry.name) || entry.name.endsWith(".ncpkg"))
|
||||
) {
|
||||
if (options.allowIgnoredRootEntries === false) {
|
||||
throw new Error(`Installed plugin contains an unpackaged root entry: ${entry.name}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const relativePath = relativeDirectory
|
||||
? `${relativeDirectory}/${entry.name}`
|
||||
: entry.name;
|
||||
const packagePath = assertSafePackagePath(relativePath);
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
const fileStats = await lstat(absolutePath);
|
||||
if (fileStats.isSymbolicLink()) {
|
||||
throw new Error(`Symbolic links are not allowed in plugin packages: ${packagePath}`);
|
||||
}
|
||||
if (fileStats.isDirectory()) {
|
||||
await visit(absolutePath, packagePath);
|
||||
continue;
|
||||
}
|
||||
if (!fileStats.isFile()) {
|
||||
throw new Error(`Only regular files are allowed in plugin packages: ${packagePath}`);
|
||||
}
|
||||
registry.add(packagePath);
|
||||
if (files.length + 1 > PACKAGE_LIMITS.fileCount) {
|
||||
throw new Error(`Plugin package exceeds ${PACKAGE_LIMITS.fileCount} files`);
|
||||
}
|
||||
if (fileStats.size > PACKAGE_LIMITS.singleFileBytes) {
|
||||
throw new Error(`Plugin file exceeds ${PACKAGE_LIMITS.singleFileBytes} bytes: ${packagePath}`);
|
||||
}
|
||||
totalBytes += fileStats.size;
|
||||
if (totalBytes > PACKAGE_LIMITS.uncompressedBytes) {
|
||||
throw new Error(
|
||||
`Plugin package exceeds ${PACKAGE_LIMITS.uncompressedBytes} uncompressed bytes`,
|
||||
);
|
||||
}
|
||||
const hashes = await hashFile(absolutePath, PACKAGE_LIMITS.singleFileBytes);
|
||||
if (hashes.size !== fileStats.size || hashes.mode !== fileStats.mode) {
|
||||
throw new Error(`Package source changed while it was being scanned: ${packagePath}`);
|
||||
}
|
||||
const isExecutable = isExecutablePackageFile(packagePath, hashes.mode);
|
||||
if (isExecutable && !companionPaths.has(packagePath)) {
|
||||
throw new Error(`Executable file is not declared as a companion: ${packagePath}`);
|
||||
}
|
||||
const companion = companionPaths.get(packagePath);
|
||||
if (companion && companion.sha256 !== hashes.sha256) {
|
||||
throw new Error(`Companion SHA-256 mismatch: ${packagePath}`);
|
||||
}
|
||||
files.push({
|
||||
absolutePath,
|
||||
packagePath,
|
||||
size: fileStats.size,
|
||||
executable: Boolean(companion),
|
||||
crc32: hashes.crc32,
|
||||
sha256: hashes.sha256,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await visit(pluginDirectory, "");
|
||||
assertManifestSnapshotMatches(
|
||||
manifestSource,
|
||||
files.find(({ packagePath }) => packagePath === "netcatty.plugin.json"),
|
||||
);
|
||||
const packagedPaths = new Set(files.map(({ packagePath }) => packagePath));
|
||||
const requiredPaths = [
|
||||
"netcatty.plugin.json",
|
||||
manifest.main.browser,
|
||||
manifest.main.node,
|
||||
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
|
||||
...(manifest.contributes?.commands ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
...(manifest.contributes?.menus ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
...(manifest.contributes?.views ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
...companionPaths.keys(),
|
||||
].filter((entryPath): entryPath is string => Boolean(entryPath));
|
||||
for (const requiredPath of requiredPaths) {
|
||||
if (!packagedPaths.has(requiredPath)) {
|
||||
throw new Error(`Manifest references a missing package file: ${requiredPath}`);
|
||||
}
|
||||
}
|
||||
return files.sort(sortPackagePaths);
|
||||
}
|
||||
|
||||
function makeLocalHeader(file: ScannedFile): Buffer {
|
||||
const name = Buffer.from(file.packagePath, "utf8");
|
||||
const header = Buffer.alloc(30 + name.byteLength);
|
||||
header.writeUInt32LE(0x04034b50, 0);
|
||||
header.writeUInt16LE(20, 4);
|
||||
header.writeUInt16LE(0x0800, 6);
|
||||
header.writeUInt16LE(0, 8);
|
||||
header.writeUInt16LE(0, 10);
|
||||
header.writeUInt16LE(33, 12);
|
||||
header.writeUInt32LE(file.crc32, 14);
|
||||
header.writeUInt32LE(file.size, 18);
|
||||
header.writeUInt32LE(file.size, 22);
|
||||
header.writeUInt16LE(name.byteLength, 26);
|
||||
header.writeUInt16LE(0, 28);
|
||||
name.copy(header, 30);
|
||||
return header;
|
||||
}
|
||||
|
||||
function makeCentralHeader(file: ScannedFile, localOffset: number): Buffer {
|
||||
const name = Buffer.from(file.packagePath, "utf8");
|
||||
const header = Buffer.alloc(46 + name.byteLength);
|
||||
header.writeUInt32LE(0x02014b50, 0);
|
||||
header.writeUInt16LE(0x0314, 4);
|
||||
header.writeUInt16LE(20, 6);
|
||||
header.writeUInt16LE(0x0800, 8);
|
||||
header.writeUInt16LE(0, 10);
|
||||
header.writeUInt16LE(0, 12);
|
||||
header.writeUInt16LE(33, 14);
|
||||
header.writeUInt32LE(file.crc32, 16);
|
||||
header.writeUInt32LE(file.size, 20);
|
||||
header.writeUInt32LE(file.size, 24);
|
||||
header.writeUInt16LE(name.byteLength, 28);
|
||||
header.writeUInt16LE(0, 30);
|
||||
header.writeUInt16LE(0, 32);
|
||||
header.writeUInt16LE(0, 34);
|
||||
header.writeUInt16LE(0, 36);
|
||||
const mode = file.executable ? 0o100755 : 0o100644;
|
||||
header.writeUInt32LE((mode << 16) >>> 0, 38);
|
||||
header.writeUInt32LE(localOffset, 42);
|
||||
name.copy(header, 46);
|
||||
return header;
|
||||
}
|
||||
|
||||
function makeEndOfCentralDirectory(
|
||||
entryCount: number,
|
||||
centralSize: number,
|
||||
centralOffset: number,
|
||||
): Buffer {
|
||||
const footer = Buffer.alloc(22);
|
||||
footer.writeUInt32LE(0x06054b50, 0);
|
||||
footer.writeUInt16LE(0, 4);
|
||||
footer.writeUInt16LE(0, 6);
|
||||
footer.writeUInt16LE(entryCount, 8);
|
||||
footer.writeUInt16LE(entryCount, 10);
|
||||
footer.writeUInt32LE(centralSize, 12);
|
||||
footer.writeUInt32LE(centralOffset, 16);
|
||||
footer.writeUInt16LE(0, 20);
|
||||
return footer;
|
||||
}
|
||||
|
||||
async function writeBuffer(output: ReturnType<typeof createWriteStream>, buffer: Buffer) {
|
||||
if (!output.write(buffer)) await once(output, "drain");
|
||||
}
|
||||
|
||||
async function writeArchive(files: readonly ScannedFile[], outputPath: string): Promise<void> {
|
||||
await mkdir(path.dirname(outputPath), { recursive: true });
|
||||
const temporaryPath = `${outputPath}.tmp-${process.pid}-${randomUUID()}`;
|
||||
const output = createWriteStream(temporaryPath, { flags: "wx", mode: 0o600 });
|
||||
const centralHeaders: Buffer[] = [];
|
||||
let offset = 0;
|
||||
try {
|
||||
await once(output, "open");
|
||||
for (const file of files) {
|
||||
const localHeader = makeLocalHeader(file);
|
||||
centralHeaders.push(makeCentralHeader(file, offset));
|
||||
await writeBuffer(output, localHeader);
|
||||
offset += localHeader.byteLength;
|
||||
const sha256 = createHash("sha256");
|
||||
let crc = 0xffffffff;
|
||||
let writtenBytes = 0;
|
||||
const input = await open(
|
||||
file.absolutePath,
|
||||
constants.O_RDONLY | ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0),
|
||||
);
|
||||
try {
|
||||
const inputStats = await input.stat();
|
||||
if (!inputStats.isFile() || inputStats.size !== file.size) {
|
||||
throw new Error(`Package source changed before archive write: ${file.packagePath}`);
|
||||
}
|
||||
const stream = createReadStream(file.absolutePath, { fd: input.fd, autoClose: false });
|
||||
for await (const chunk of stream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
writtenBytes += buffer.byteLength;
|
||||
if (writtenBytes > file.size) {
|
||||
throw new Error(`Package source changed while it was being written: ${file.packagePath}`);
|
||||
}
|
||||
sha256.update(buffer);
|
||||
crc = updateCrc32(crc, buffer);
|
||||
await writeBuffer(output, buffer);
|
||||
offset += buffer.byteLength;
|
||||
}
|
||||
} finally {
|
||||
await input.close();
|
||||
}
|
||||
if (
|
||||
writtenBytes !== file.size
|
||||
|| ((crc ^ 0xffffffff) >>> 0) !== file.crc32
|
||||
|| sha256.digest("hex") !== file.sha256
|
||||
) {
|
||||
throw new Error(`Package source changed while it was being written: ${file.packagePath}`);
|
||||
}
|
||||
}
|
||||
const centralOffset = offset;
|
||||
for (const centralHeader of centralHeaders) {
|
||||
await writeBuffer(output, centralHeader);
|
||||
offset += centralHeader.byteLength;
|
||||
}
|
||||
await writeBuffer(
|
||||
output,
|
||||
makeEndOfCentralDirectory(files.length, offset - centralOffset, centralOffset),
|
||||
);
|
||||
output.end();
|
||||
await once(output, "close");
|
||||
const archiveStats = await stat(temporaryPath);
|
||||
if (archiveStats.size > PACKAGE_LIMITS.archiveBytes) {
|
||||
throw new Error(`Plugin archive exceeds ${PACKAGE_LIMITS.archiveBytes} bytes`);
|
||||
}
|
||||
await rename(temporaryPath, outputPath);
|
||||
} catch (error) {
|
||||
output.destroy();
|
||||
await rm(temporaryPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildPluginPackage(
|
||||
pluginDirectory: string,
|
||||
outputPath: string,
|
||||
): Promise<PackageBuildResult> {
|
||||
const sourceDirectory = path.resolve(pluginDirectory);
|
||||
const resolvedOutputPath = path.resolve(outputPath);
|
||||
if (!resolvedOutputPath.endsWith(".ncpkg")) {
|
||||
throw new Error("Plugin package output must use the .ncpkg extension");
|
||||
}
|
||||
const canonicalSourceDirectory = await realpath(sourceDirectory);
|
||||
const canonicalOutputPath = await resolveThroughExistingAncestor(resolvedOutputPath);
|
||||
if (
|
||||
isSameOrDescendantPath(sourceDirectory, resolvedOutputPath)
|
||||
|| isSameOrDescendantPath(canonicalSourceDirectory, canonicalOutputPath)
|
||||
) {
|
||||
throw new Error("Plugin package output must be outside the plugin source directory");
|
||||
}
|
||||
const manifestSource = await readValidatedManifestSource(sourceDirectory);
|
||||
const files = await scanPackageDirectory(sourceDirectory, manifestSource);
|
||||
await writeArchive(files, resolvedOutputPath);
|
||||
const outputStats = await stat(resolvedOutputPath);
|
||||
const archiveHash = await hashFile(resolvedOutputPath, PACKAGE_LIMITS.archiveBytes);
|
||||
return {
|
||||
outputPath: resolvedOutputPath,
|
||||
fileCount: files.length,
|
||||
uncompressedBytes: files.reduce((sum, file) => sum + file.size, 0),
|
||||
archiveBytes: outputStats.size,
|
||||
sha256: archiveHash.sha256,
|
||||
contentSha256: computePackageContentSha256(files),
|
||||
};
|
||||
}
|
||||
|
||||
export async function validatePluginDirectory(
|
||||
pluginDirectory: string,
|
||||
options: { allowIgnoredRootEntries?: boolean } = {},
|
||||
): Promise<PluginDirectoryValidationResult> {
|
||||
const sourceDirectory = path.resolve(pluginDirectory);
|
||||
const manifestSource = await readValidatedManifestSource(sourceDirectory);
|
||||
const files = await scanPackageDirectory(sourceDirectory, manifestSource, options);
|
||||
return {
|
||||
manifest: manifestSource.manifest,
|
||||
fileCount: files.length,
|
||||
uncompressedBytes: files.reduce((sum, file) => sum + file.size, 0),
|
||||
contentSha256: computePackageContentSha256(files),
|
||||
};
|
||||
}
|
||||
|
||||
function openZip(filePath: string): Promise<ZipFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
yauzl.open(
|
||||
filePath,
|
||||
// Security invariant: for deflated entries, validateEntrySizes inserts an
|
||||
// AssertByteCountStream that rejects an overflowing chunk before it is
|
||||
// forwarded to readEntry(). Combined with the metadata limits below,
|
||||
// captured manifest contents cannot exceed PACKAGE_LIMITS.manifestBytes.
|
||||
{ lazyEntries: true, decodeStrings: true, strictFileNames: true, validateEntrySizes: true },
|
||||
(error, zipFile) => {
|
||||
if (error || !zipFile) reject(error ?? new Error("Unable to open plugin archive"));
|
||||
else resolve(zipFile);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
interface ReadArchiveEntryResult {
|
||||
readonly bytes: number;
|
||||
readonly crc32: number;
|
||||
readonly sha256: string;
|
||||
readonly contents?: Buffer;
|
||||
}
|
||||
|
||||
interface LocalFileHeader {
|
||||
readonly generalPurposeBitFlag: number;
|
||||
readonly compressionMethod: number;
|
||||
readonly crc32: number;
|
||||
readonly compressedSize: number;
|
||||
readonly uncompressedSize: number;
|
||||
readonly fileName: Buffer;
|
||||
}
|
||||
|
||||
interface ZipEntryWithRawName extends Entry {
|
||||
readonly fileNameRaw?: Buffer;
|
||||
}
|
||||
|
||||
interface ZipFileWithLocalHeader extends ZipFile {
|
||||
readLocalFileHeader(
|
||||
entry: Entry,
|
||||
callback: (error: Error | null, header?: LocalFileHeader) => void,
|
||||
): void;
|
||||
}
|
||||
|
||||
function readLocalFileHeader(zipFile: ZipFile, entry: Entry): Promise<LocalFileHeader> {
|
||||
return new Promise((resolve, reject) => {
|
||||
(zipFile as ZipFileWithLocalHeader).readLocalFileHeader(entry, (error, header) => {
|
||||
if (error || !header) {
|
||||
reject(error ?? new Error(`Unable to read local ZIP header: ${entry.fileName}`));
|
||||
} else {
|
||||
resolve(header);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function validateLocalFileHeader(
|
||||
zipFile: ZipFile,
|
||||
entry: Entry,
|
||||
packagePath: string,
|
||||
): Promise<void> {
|
||||
if ((entry.generalPurposeBitFlag & 0x0800) === 0) {
|
||||
throw new Error(`ZIP entry name must use UTF-8 encoding: ${packagePath}`);
|
||||
}
|
||||
if ((entry.generalPurposeBitFlag & 0x0008) !== 0) {
|
||||
throw new Error(`ZIP data descriptors are not allowed: ${packagePath}`);
|
||||
}
|
||||
const centralName = (entry as ZipEntryWithRawName).fileNameRaw;
|
||||
if (!centralName || !centralName.equals(Buffer.from(entry.fileName, "utf8"))) {
|
||||
throw new Error(`ZIP central entry name is not canonical UTF-8: ${packagePath}`);
|
||||
}
|
||||
const local = await readLocalFileHeader(zipFile, entry);
|
||||
if (!local.fileName.equals(centralName)) {
|
||||
throw new Error(`ZIP local and central entry names differ: ${packagePath}`);
|
||||
}
|
||||
if (local.generalPurposeBitFlag !== entry.generalPurposeBitFlag) {
|
||||
throw new Error(`ZIP local and central entry flags differ: ${packagePath}`);
|
||||
}
|
||||
if (local.compressionMethod !== entry.compressionMethod) {
|
||||
throw new Error(`ZIP local and central compression methods differ: ${packagePath}`);
|
||||
}
|
||||
if (
|
||||
local.crc32 !== entry.crc32
|
||||
|| local.compressedSize !== entry.compressedSize
|
||||
|| local.uncompressedSize !== entry.uncompressedSize
|
||||
) {
|
||||
throw new Error(`ZIP local and central integrity metadata differ: ${packagePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readEntry(
|
||||
zipFile: ZipFile,
|
||||
entry: Entry,
|
||||
captureContents: boolean,
|
||||
outputPath?: string,
|
||||
): Promise<ReadArchiveEntryResult> {
|
||||
const stream = await new Promise<NodeJS.ReadableStream>((resolve, reject) => {
|
||||
zipFile.openReadStream(entry, (error, stream) => {
|
||||
if (error || !stream) {
|
||||
reject(error ?? new Error(`Unable to read archive entry: ${entry.fileName}`));
|
||||
return;
|
||||
}
|
||||
resolve(stream);
|
||||
});
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
const sha256 = createHash("sha256");
|
||||
let crc = 0xffffffff;
|
||||
let bytes = 0;
|
||||
let outputHandle;
|
||||
try {
|
||||
if (outputPath) {
|
||||
await mkdir(path.dirname(outputPath), { recursive: true, mode: 0o700 });
|
||||
outputHandle = await open(outputPath, "wx", 0o600);
|
||||
}
|
||||
for await (const chunk of stream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
bytes += buffer.byteLength;
|
||||
if (bytes > PACKAGE_LIMITS.singleFileBytes) {
|
||||
throw new Error(`Plugin file exceeds size limit: ${entry.fileName}`);
|
||||
}
|
||||
sha256.update(buffer);
|
||||
crc = updateCrc32(crc, buffer);
|
||||
if (captureContents) chunks.push(buffer);
|
||||
if (outputHandle) {
|
||||
let written = 0;
|
||||
while (written < buffer.length) {
|
||||
const result = await outputHandle.write(buffer, written, buffer.length - written);
|
||||
if (result.bytesWritten === 0) {
|
||||
throw new Error(`Unable to extract the complete archive entry: ${entry.fileName}`);
|
||||
}
|
||||
written += result.bytesWritten;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (outputHandle) await outputHandle.sync();
|
||||
return {
|
||||
bytes,
|
||||
crc32: (crc ^ 0xffffffff) >>> 0,
|
||||
sha256: sha256.digest("hex"),
|
||||
contents: captureContents ? Buffer.concat(chunks) : undefined,
|
||||
};
|
||||
} finally {
|
||||
await outputHandle?.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectPluginPackage(
|
||||
archivePath: string,
|
||||
extractionDirectory?: string,
|
||||
): Promise<PackageValidationResult> {
|
||||
const archiveStats = await stat(archivePath);
|
||||
if (archiveStats.size > PACKAGE_LIMITS.archiveBytes) {
|
||||
throw new Error(`Plugin archive exceeds ${PACKAGE_LIMITS.archiveBytes} bytes`);
|
||||
}
|
||||
const zipFile = await openZip(archivePath);
|
||||
const registry = new PackagePathRegistry();
|
||||
const entries = new Map<string, {
|
||||
contents?: Buffer;
|
||||
mode: number;
|
||||
sha256: string;
|
||||
size: number;
|
||||
}>();
|
||||
let totalBytes = 0;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const fail = (error: unknown) => {
|
||||
zipFile.close();
|
||||
reject(error);
|
||||
};
|
||||
zipFile.once("error", fail);
|
||||
zipFile.once("end", resolve);
|
||||
zipFile.on("entry", (entry: Entry) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const packagePath = registry.add(entry.fileName);
|
||||
if (entries.size + 1 > PACKAGE_LIMITS.fileCount) {
|
||||
throw new Error(`Plugin package exceeds ${PACKAGE_LIMITS.fileCount} files`);
|
||||
}
|
||||
if ((entry.generalPurposeBitFlag & 1) !== 0) {
|
||||
throw new Error(`Encrypted ZIP entries are not allowed: ${packagePath}`);
|
||||
}
|
||||
if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) {
|
||||
throw new Error(`Unsupported ZIP compression method: ${packagePath}`);
|
||||
}
|
||||
await validateLocalFileHeader(zipFile, entry, packagePath);
|
||||
const mode = (entry.externalFileAttributes >>> 16) & 0xffff;
|
||||
const fileType = mode & 0o170000;
|
||||
if (fileType !== 0 && fileType !== 0o100000) {
|
||||
throw new Error(`Only regular files are allowed: ${packagePath}`);
|
||||
}
|
||||
if (entry.uncompressedSize > PACKAGE_LIMITS.singleFileBytes) {
|
||||
throw new Error(`Plugin file exceeds size limit: ${packagePath}`);
|
||||
}
|
||||
if (
|
||||
packagePath === "netcatty.plugin.json"
|
||||
&& entry.uncompressedSize > PACKAGE_LIMITS.manifestBytes
|
||||
) {
|
||||
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
|
||||
}
|
||||
totalBytes += entry.uncompressedSize;
|
||||
if (totalBytes > PACKAGE_LIMITS.uncompressedBytes) {
|
||||
throw new Error("Plugin package exceeds the uncompressed size limit");
|
||||
}
|
||||
const result = await readEntry(
|
||||
zipFile,
|
||||
entry,
|
||||
packagePath === "netcatty.plugin.json",
|
||||
extractionDirectory ? path.join(extractionDirectory, ...packagePath.split("/")) : undefined,
|
||||
);
|
||||
if (result.bytes !== entry.uncompressedSize || result.crc32 !== entry.crc32) {
|
||||
throw new Error(`ZIP entry integrity check failed: ${packagePath}`);
|
||||
}
|
||||
entries.set(packagePath, {
|
||||
contents: result.contents,
|
||||
mode,
|
||||
sha256: result.sha256,
|
||||
size: result.bytes,
|
||||
});
|
||||
zipFile.readEntry();
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
zipFile.readEntry();
|
||||
});
|
||||
|
||||
const manifestEntry = entries.get("netcatty.plugin.json");
|
||||
if (!manifestEntry?.contents) throw new Error("Plugin package is missing netcatty.plugin.json");
|
||||
const manifest = parseAndValidateManifestContents(manifestEntry.contents);
|
||||
const declaredCompanions = new Map(
|
||||
(manifest.companionExecutables ?? []).flatMap((companion) => (
|
||||
companion.variants.map((variant) => [variant.path, variant] as const)
|
||||
)),
|
||||
);
|
||||
for (const [packagePath, entry] of entries) {
|
||||
const isExecutable = isExecutablePackageFile(packagePath, entry.mode);
|
||||
if (isExecutable && !declaredCompanions.has(packagePath)) {
|
||||
throw new Error(`Executable file is not declared as a companion: ${packagePath}`);
|
||||
}
|
||||
}
|
||||
const requiredPaths = [
|
||||
manifest.main.browser,
|
||||
manifest.main.node,
|
||||
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
|
||||
...(manifest.contributes?.commands ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
...(manifest.contributes?.menus ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
...(manifest.contributes?.views ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
].filter((entryPath): entryPath is string => Boolean(entryPath));
|
||||
for (const requiredPath of requiredPaths) {
|
||||
if (!entries.has(requiredPath)) {
|
||||
throw new Error(`Manifest references a missing package file: ${requiredPath}`);
|
||||
}
|
||||
}
|
||||
for (const [companionPath, companion] of declaredCompanions) {
|
||||
const entry = entries.get(companionPath);
|
||||
if (!entry) throw new Error(`Manifest references a missing companion: ${companionPath}`);
|
||||
if (entry.sha256 !== companion.sha256) {
|
||||
throw new Error(`Companion SHA-256 mismatch: ${companionPath}`);
|
||||
}
|
||||
}
|
||||
if (extractionDirectory) {
|
||||
for (const [packagePath] of entries) {
|
||||
await chmod(
|
||||
path.join(extractionDirectory, ...packagePath.split("/")),
|
||||
declaredCompanions.has(packagePath) ? 0o700 : 0o600,
|
||||
);
|
||||
}
|
||||
}
|
||||
const contentSha256 = computePackageContentSha256(
|
||||
[...entries].map(([packagePath, entry]) => ({
|
||||
packagePath,
|
||||
size: entry.size,
|
||||
sha256: entry.sha256,
|
||||
executable: declaredCompanions.has(packagePath),
|
||||
})),
|
||||
);
|
||||
return { manifest, fileCount: entries.size, uncompressedBytes: totalBytes, contentSha256 };
|
||||
}
|
||||
|
||||
export async function validatePluginPackage(
|
||||
archivePath: string,
|
||||
): Promise<PackageValidationResult> {
|
||||
return inspectPluginPackage(archivePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and extracts an immutable package snapshot into a new directory.
|
||||
* Failed or incomplete destinations are always removed so only callers that
|
||||
* receive a successful result can atomically publish the staged directory.
|
||||
*/
|
||||
export async function extractPluginPackage(
|
||||
archivePath: string,
|
||||
destinationDirectory: string,
|
||||
): Promise<PackageValidationResult> {
|
||||
await mkdir(destinationDirectory, { recursive: false, mode: 0o700 });
|
||||
try {
|
||||
return await inspectPluginPackage(archivePath, destinationDirectory);
|
||||
} catch (error) {
|
||||
await rm(destinationDirectory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
943
packages/plugin-cli/src/cli.test.ts
Normal file
943
packages/plugin-cli/src/cli.test.ts
Normal file
@@ -0,0 +1,943 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
truncate,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
assertManifestSnapshotMatches,
|
||||
buildPluginPackage,
|
||||
extractPluginPackage,
|
||||
hashFile,
|
||||
validatePluginDirectory,
|
||||
validatePluginPackage,
|
||||
} from "./archive.ts";
|
||||
import { checkPluginCompatibility } from "./compatibility.ts";
|
||||
import { PACKAGE_LIMITS } from "./constants.ts";
|
||||
import { initPlugin } from "./commands.ts";
|
||||
import {
|
||||
parseAndValidateManifestContents,
|
||||
readAndValidateManifest,
|
||||
validateManifestValue,
|
||||
} from "./manifest.ts";
|
||||
import { assertSafePackagePath, PackagePathRegistry } from "./packagePath.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const cliPath = fileURLToPath(new URL("./cli.ts", import.meta.url));
|
||||
const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
|
||||
function crc32(contents: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of contents) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
crc = (crc & 1) === 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function createZipEntry(
|
||||
entryName: string,
|
||||
contents: Buffer,
|
||||
options: {
|
||||
readonly compressionMethod?: 0 | 8;
|
||||
readonly declaredUncompressedSize?: number;
|
||||
} = {},
|
||||
): Buffer {
|
||||
const compressionMethod = options.compressionMethod ?? 0;
|
||||
const encodedContents = compressionMethod === 8 ? deflateRawSync(contents) : contents;
|
||||
const declaredUncompressedSize = options.declaredUncompressedSize ?? contents.byteLength;
|
||||
const encodedName = Buffer.from(entryName);
|
||||
const checksum = crc32(contents);
|
||||
const localHeader = Buffer.alloc(30 + encodedName.byteLength);
|
||||
localHeader.writeUInt32LE(0x04034b50, 0);
|
||||
localHeader.writeUInt16LE(20, 4);
|
||||
localHeader.writeUInt16LE(0x0800, 6);
|
||||
localHeader.writeUInt16LE(compressionMethod, 8);
|
||||
localHeader.writeUInt16LE(0, 10);
|
||||
localHeader.writeUInt16LE(33, 12);
|
||||
localHeader.writeUInt32LE(checksum, 14);
|
||||
localHeader.writeUInt32LE(encodedContents.byteLength, 18);
|
||||
localHeader.writeUInt32LE(declaredUncompressedSize, 22);
|
||||
localHeader.writeUInt16LE(encodedName.byteLength, 26);
|
||||
encodedName.copy(localHeader, 30);
|
||||
|
||||
const centralHeader = Buffer.alloc(46 + encodedName.byteLength);
|
||||
centralHeader.writeUInt32LE(0x02014b50, 0);
|
||||
centralHeader.writeUInt16LE(20, 4);
|
||||
centralHeader.writeUInt16LE(20, 6);
|
||||
centralHeader.writeUInt16LE(0x0800, 8);
|
||||
centralHeader.writeUInt16LE(compressionMethod, 10);
|
||||
centralHeader.writeUInt16LE(0, 12);
|
||||
centralHeader.writeUInt16LE(33, 14);
|
||||
centralHeader.writeUInt32LE(checksum, 16);
|
||||
centralHeader.writeUInt32LE(encodedContents.byteLength, 20);
|
||||
centralHeader.writeUInt32LE(declaredUncompressedSize, 24);
|
||||
centralHeader.writeUInt16LE(encodedName.byteLength, 28);
|
||||
centralHeader.writeUInt32LE((0o100644 << 16) >>> 0, 38);
|
||||
centralHeader.writeUInt32LE(0, 42);
|
||||
encodedName.copy(centralHeader, 46);
|
||||
|
||||
const centralOffset = localHeader.byteLength + encodedContents.byteLength;
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(1, 8);
|
||||
end.writeUInt16LE(1, 10);
|
||||
end.writeUInt32LE(centralHeader.byteLength, 12);
|
||||
end.writeUInt32LE(centralOffset, 16);
|
||||
return Buffer.concat([localHeader, encodedContents, centralHeader, end]);
|
||||
}
|
||||
|
||||
function replaceCentralEntryMode(archive: Buffer, entryName: string, mode: number): Buffer {
|
||||
const result = Buffer.from(archive);
|
||||
const endOffset = result.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
|
||||
if (endOffset < 0) throw new Error("Missing ZIP end record");
|
||||
const entryCount = result.readUInt16LE(endOffset + 10);
|
||||
let offset = result.readUInt32LE(endOffset + 16);
|
||||
for (let entry = 0; entry < entryCount; entry += 1) {
|
||||
if (result.readUInt32LE(offset) !== 0x02014b50) throw new Error("Invalid central ZIP entry");
|
||||
const nameLength = result.readUInt16LE(offset + 28);
|
||||
const extraLength = result.readUInt16LE(offset + 30);
|
||||
const commentLength = result.readUInt16LE(offset + 32);
|
||||
const name = result.subarray(offset + 46, offset + 46 + nameLength).toString("utf8");
|
||||
if (name === entryName) {
|
||||
result.writeUInt32LE((mode << 16) >>> 0, offset + 38);
|
||||
return result;
|
||||
}
|
||||
offset += 46 + nameLength + extraLength + commentLength;
|
||||
}
|
||||
throw new Error(`Missing central ZIP entry: ${entryName}`);
|
||||
}
|
||||
|
||||
function manifest(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
manifestVersion: 1,
|
||||
id: "com.example.package-test",
|
||||
name: "package-test",
|
||||
version: "1.0.0",
|
||||
publisher: "example",
|
||||
engines: { netcatty: ">=1.0.0 <2.0.0", api: ">=0.1.0-internal <0.2.0" },
|
||||
main: { browser: "dist/index.js" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function createPlugin(root: string): Promise<string> {
|
||||
const directory = path.join(root, "plugin");
|
||||
await mkdir(path.join(directory, "dist"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(directory, "netcatty.plugin.json"),
|
||||
`${JSON.stringify(manifest(), null, 2)}\n`,
|
||||
),
|
||||
writeFile(path.join(directory, "dist/index.js"), "export default {};\n"),
|
||||
writeFile(path.join(directory, "README.md"), "# Package test\n"),
|
||||
]);
|
||||
return directory;
|
||||
}
|
||||
|
||||
test("path validation rejects traversal, platform aliases, and duplicates", () => {
|
||||
for (const unsafe of [
|
||||
"../escape",
|
||||
"/absolute",
|
||||
"C:/drive",
|
||||
"a\\b",
|
||||
"a/../b",
|
||||
"CON",
|
||||
"assets/PRN.txt",
|
||||
"file.",
|
||||
"folder/file ",
|
||||
"folder/file?.js",
|
||||
"a//b",
|
||||
"a/b",
|
||||
"a\b",
|
||||
"../x",
|
||||
"..",
|
||||
"C:/x",
|
||||
"assets/CON.txt",
|
||||
"file.",
|
||||
"😀".repeat(129),
|
||||
]) {
|
||||
assert.throws(() => assertSafePackagePath(unsafe));
|
||||
}
|
||||
assert.equal(assertSafePackagePath("😀".repeat(128)), "😀".repeat(128));
|
||||
assert.equal(assertSafePackagePath("assets/fullwidth-S.txt"), "assets/fullwidth-S.txt");
|
||||
const registry = new PackagePathRegistry();
|
||||
registry.add("dist/Plugin.js");
|
||||
assert.throws(() => registry.add("dist/plugin.js"), /case-colliding/);
|
||||
|
||||
for (const [first, second] of [
|
||||
["assets/Straße.txt", "assets/STRASSE.txt"],
|
||||
["assets/fullwidth-S.txt", "assets/fullwidth-S.txt"],
|
||||
]) {
|
||||
const unicodeRegistry = new PackagePathRegistry();
|
||||
unicodeRegistry.add(first);
|
||||
assert.throws(() => unicodeRegistry.add(second), /case-colliding/);
|
||||
}
|
||||
|
||||
for (const paths of [
|
||||
["dist", "dist/index.js"],
|
||||
["dist/index.js", "dist"],
|
||||
["DIST", "dist/index.js"],
|
||||
["dist/index.js", "DIST"],
|
||||
]) {
|
||||
const collisionRegistry = new PackagePathRegistry();
|
||||
collisionRegistry.add(paths[0]);
|
||||
assert.throws(
|
||||
() => collisionRegistry.add(paths[1]),
|
||||
/File\/directory package path collision/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("manifest validation reports permission and contribution mistakes", () => {
|
||||
const result = validateManifestValue(manifest({
|
||||
permissions: {
|
||||
required: [{ permission: "network", resources: ["https://example.com"] }],
|
||||
optional: ["network"],
|
||||
},
|
||||
contributes: {
|
||||
commands: [{ id: "com.example.package-test.run", title: "Run" }],
|
||||
menus: [{ command: "com.example.package-test.missing", location: "commandPalette" }],
|
||||
},
|
||||
}));
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.errors.join("\n"), /both required and optional/);
|
||||
assert.match(result.errors.join("\n"), /undeclared command/);
|
||||
});
|
||||
|
||||
test("text setting defaults must satisfy their declared pattern", () => {
|
||||
const result = validateManifestValue(manifest({
|
||||
permissions: { required: ["settings.read", "settings.write"] },
|
||||
contributes: {
|
||||
settings: [{
|
||||
id: "com.example.package-test.channel",
|
||||
label: "Channel",
|
||||
control: "text",
|
||||
scope: "application",
|
||||
pattern: "^(stable|beta)$",
|
||||
default: "nightly",
|
||||
}],
|
||||
},
|
||||
}));
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.errors.join("\n"), /default does not match its pattern/u);
|
||||
});
|
||||
|
||||
test("resource-scoped required permissions must declare activation-time bounds", () => {
|
||||
for (const [permission, resource] of [
|
||||
["network", "https://example.com"],
|
||||
["filesystem.read", "/tmp/plugin-read"],
|
||||
["filesystem.write", "/tmp/plugin-write"],
|
||||
["companion.execute", "com.example.package-test.helper"],
|
||||
] as const) {
|
||||
const unbounded = validateManifestValue(manifest({
|
||||
permissions: { required: [permission] },
|
||||
}));
|
||||
assert.equal(unbounded.valid, false, permission);
|
||||
assert.match(unbounded.errors.join("\n"), /must declare explicit resources/u);
|
||||
|
||||
const bounded = validateManifestValue(manifest({
|
||||
permissions: { required: [{ permission, resources: [resource] }] },
|
||||
}));
|
||||
assert.equal(bounded.valid, true, bounded.errors.join("\n"));
|
||||
|
||||
const wildcard = validateManifestValue(manifest({
|
||||
permissions: { required: [{ permission, resources: ["*"] }] },
|
||||
}));
|
||||
assert.equal(wildcard.valid, false, permission);
|
||||
assert.match(wildcard.errors.join("\n"), /must not use the wildcard resource/u);
|
||||
|
||||
const optional = validateManifestValue(manifest({
|
||||
permissions: { optional: [permission] },
|
||||
}));
|
||||
assert.equal(optional.valid, true, optional.errors.join("\n"));
|
||||
}
|
||||
});
|
||||
|
||||
test("manifest validation applies the runtime resource limit for each permission", () => {
|
||||
for (const [permission, length, expectedLimit] of [
|
||||
["network", 2_049, 2_048],
|
||||
["storage", 2_049, 2_048],
|
||||
["companion.execute", 193, 192],
|
||||
] as const) {
|
||||
const result = validateManifestValue(manifest({
|
||||
permissions: permission === "storage"
|
||||
? { optional: [{ permission, resources: ["x".repeat(length)] }] }
|
||||
: { required: [{ permission, resources: ["x".repeat(length)] }] },
|
||||
}));
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(
|
||||
result.errors.join("\n"),
|
||||
new RegExp(`Permission resource for ${permission.replace(".", "\\.")} exceeds ${expectedLimit}`),
|
||||
);
|
||||
}
|
||||
|
||||
const filesystem = validateManifestValue(manifest({
|
||||
permissions: {
|
||||
required: [{ permission: "filesystem.read", resources: [`/${"x".repeat(4_096)}`] }],
|
||||
},
|
||||
}));
|
||||
assert.equal(filesystem.valid, true, filesystem.errors.join("\n"));
|
||||
});
|
||||
|
||||
test("Node utility entrypoints require the explicit advanced-runtime permission", () => {
|
||||
const missing = validateManifestValue(manifest({
|
||||
main: { node: "dist/index.js" },
|
||||
}));
|
||||
assert.equal(missing.valid, false);
|
||||
assert.match(missing.errors.join("\n"), /requires declared permission: runtime\.advanced/);
|
||||
|
||||
const declared = validateManifestValue(manifest({
|
||||
main: { node: "dist/index.js" },
|
||||
permissions: { required: ["runtime.advanced"] },
|
||||
}));
|
||||
assert.equal(declared.valid, true, declared.errors.join("\n"));
|
||||
});
|
||||
|
||||
test("manifest byte parsing rejects invalid UTF-8 before JSON validation", () => {
|
||||
const validContents = new TextEncoder().encode(JSON.stringify(manifest()));
|
||||
assert.equal(parseAndValidateManifestContents(validContents).id, "com.example.package-test");
|
||||
assert.throws(
|
||||
() => parseAndValidateManifestContents(Uint8Array.from([0x7b, 0x22, 0xff, 0x22, 0x7d])),
|
||||
/not valid UTF-8 JSON/,
|
||||
);
|
||||
});
|
||||
|
||||
test("manifest validation safely rejects excessive JSON nesting", () => {
|
||||
let nested: unknown = null;
|
||||
for (let depth = 0; depth < 5_000; depth += 1) {
|
||||
nested = [nested];
|
||||
}
|
||||
const result = validateManifestValue(manifest({
|
||||
permissions: { required: ["commands"] },
|
||||
contributes: {
|
||||
commands: [{ id: "com.example.package-test.run", title: "Run" }],
|
||||
keybindings: [{
|
||||
command: "com.example.package-test.run",
|
||||
key: "ctrl+x",
|
||||
args: nested,
|
||||
}],
|
||||
},
|
||||
}));
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.errors.join("\n"), /must not exceed .* levels/);
|
||||
|
||||
const wideResult = validateManifestValue(manifest({
|
||||
permissions: { required: ["commands"] },
|
||||
contributes: {
|
||||
commands: [{ id: "com.example.package-test.run", title: "Run" }],
|
||||
keybindings: [{
|
||||
command: "com.example.package-test.run",
|
||||
key: "ctrl+x",
|
||||
args: Array.from({ length: 100_000 }, () => null),
|
||||
}],
|
||||
},
|
||||
}));
|
||||
assert.equal(wideResult.valid, false);
|
||||
assert.match(wideResult.errors.join("\n"), /must not contain more than .* nodes/);
|
||||
});
|
||||
|
||||
test("manifest validation rejects duplicate companion executable paths", () => {
|
||||
const result = validateManifestValue(manifest({
|
||||
companionExecutables: [
|
||||
{
|
||||
id: "com.example.package-test.helper-one",
|
||||
variants: [{
|
||||
path: "bin/helper",
|
||||
platforms: ["linux-x64"],
|
||||
sha256: "0".repeat(64),
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: "com.example.package-test.helper-two",
|
||||
variants: [{
|
||||
path: "bin/helper",
|
||||
platforms: ["darwin-arm64"],
|
||||
sha256: "1".repeat(64),
|
||||
}],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.errors.join("\n"), /Duplicate companion executable path: bin\/helper/);
|
||||
});
|
||||
|
||||
test("manifest validation supports platform-specific companion variants", () => {
|
||||
const result = validateManifestValue(manifest({
|
||||
main: { browser: "dist/index.js", node: "dist/index.js" },
|
||||
permissions: {
|
||||
required: [
|
||||
"runtime.advanced",
|
||||
{
|
||||
permission: "companion.execute",
|
||||
resources: ["com.example.package-test.helper"],
|
||||
},
|
||||
],
|
||||
},
|
||||
companionExecutables: [{
|
||||
id: "com.example.package-test.helper",
|
||||
variants: [
|
||||
{
|
||||
path: "bin/helper-darwin",
|
||||
platforms: ["darwin-arm64", "darwin-x64"],
|
||||
sha256: "0".repeat(64),
|
||||
},
|
||||
{
|
||||
path: "bin/helper-linux",
|
||||
platforms: ["linux-arm64", "linux-x64"],
|
||||
sha256: "1".repeat(64),
|
||||
},
|
||||
],
|
||||
}],
|
||||
}));
|
||||
assert.equal(result.valid, true, result.errors.join("\n"));
|
||||
|
||||
const duplicatePlatform = validateManifestValue(manifest({
|
||||
main: { browser: "dist/index.js", node: "dist/index.js" },
|
||||
permissions: {
|
||||
required: [
|
||||
"runtime.advanced",
|
||||
{
|
||||
permission: "companion.execute",
|
||||
resources: ["com.example.package-test.helper"],
|
||||
},
|
||||
],
|
||||
},
|
||||
companionExecutables: [{
|
||||
id: "com.example.package-test.helper",
|
||||
variants: [
|
||||
{
|
||||
path: "bin/helper-one",
|
||||
platforms: ["linux-x64"],
|
||||
sha256: "0".repeat(64),
|
||||
},
|
||||
{
|
||||
path: "bin/helper-two",
|
||||
platforms: ["linux-x64"],
|
||||
sha256: "1".repeat(64),
|
||||
},
|
||||
],
|
||||
}],
|
||||
}));
|
||||
assert.equal(duplicatePlatform.valid, false);
|
||||
assert.match(duplicatePlatform.errors.join("\n"), /Duplicate companion platform/);
|
||||
});
|
||||
|
||||
test("companion executables require an advanced utility placement", () => {
|
||||
const companionExecutables = [{
|
||||
id: "com.example.package-test.helper",
|
||||
variants: [{
|
||||
path: "bin/helper",
|
||||
platforms: ["linux-x64"],
|
||||
sha256: "0".repeat(64),
|
||||
}],
|
||||
}];
|
||||
const companionPermission = {
|
||||
permission: "companion.execute" as const,
|
||||
resources: ["com.example.package-test.helper"],
|
||||
};
|
||||
|
||||
const browserOnly = validateManifestValue(manifest({
|
||||
permissions: { required: [companionPermission] },
|
||||
companionExecutables,
|
||||
}));
|
||||
assert.equal(browserOnly.valid, false);
|
||||
assert.match(browserOnly.errors.join("\n"), /require a Node utility entrypoint/u);
|
||||
|
||||
const missingAdvanced = validateManifestValue(manifest({
|
||||
main: { browser: "dist/index.js", node: "dist/index.js" },
|
||||
permissions: { required: [companionPermission] },
|
||||
companionExecutables,
|
||||
}));
|
||||
assert.equal(missingAdvanced.valid, false);
|
||||
assert.match(missingAdvanced.errors.join("\n"), /requires declared permission: runtime\.advanced/u);
|
||||
});
|
||||
|
||||
test("packaging treats contributed package icons as required safe files", async (context) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-icons-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const directory = await createPlugin(root);
|
||||
const manifestPath = path.join(directory, "netcatty.plugin.json");
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest({
|
||||
permissions: { required: ["commands"] },
|
||||
contributes: {
|
||||
commands: [{
|
||||
id: "com.example.package-test.run",
|
||||
title: "Run",
|
||||
icon: {
|
||||
kind: "package",
|
||||
light: "assets/run-light.svg",
|
||||
dark: "assets/run-dark.svg",
|
||||
},
|
||||
}],
|
||||
},
|
||||
}), null, 2)}\n`);
|
||||
|
||||
await assert.rejects(
|
||||
buildPluginPackage(directory, path.join(root, "missing-icons.ncpkg")),
|
||||
/missing package file: assets\/run-light\.svg/,
|
||||
);
|
||||
|
||||
await mkdir(path.join(directory, "assets"));
|
||||
await Promise.all([
|
||||
writeFile(path.join(directory, "assets/run-light.svg"), "<svg></svg>"),
|
||||
writeFile(path.join(directory, "assets/run-dark.svg"), "<svg></svg>"),
|
||||
]);
|
||||
const output = path.join(root, "with-icons.ncpkg");
|
||||
await buildPluginPackage(directory, output);
|
||||
const result = await validatePluginPackage(output);
|
||||
assert.equal(result.manifest.id, "com.example.package-test");
|
||||
});
|
||||
|
||||
test("compatibility checks engine ranges and negotiates declared features", () => {
|
||||
const pluginManifest = manifest({
|
||||
features: {
|
||||
required: ["netcatty.rpc.progress"],
|
||||
optional: ["netcatty.stream.binary", "netcatty.view.theme"],
|
||||
},
|
||||
});
|
||||
const compatible = checkPluginCompatibility(pluginManifest, {
|
||||
netcattyVersion: "1.4.0",
|
||||
features: ["netcatty.rpc.progress", "netcatty.stream.binary"],
|
||||
});
|
||||
assert.equal(compatible.compatible, true);
|
||||
assert.deepEqual(compatible.enabledFeatures, [
|
||||
"netcatty.rpc.progress",
|
||||
"netcatty.stream.binary",
|
||||
]);
|
||||
|
||||
const incompatible = checkPluginCompatibility(pluginManifest, {
|
||||
netcattyVersion: "2.0.0",
|
||||
apiVersion: "0.2.0",
|
||||
features: [],
|
||||
});
|
||||
assert.equal(incompatible.compatible, false);
|
||||
assert.deepEqual(incompatible.missingRequiredFeatures, ["netcatty.rpc.progress"]);
|
||||
assert.match(incompatible.errors.join("\n"), /does not satisfy/);
|
||||
assert.match(incompatible.errors.join("\n"), /Missing required features/);
|
||||
|
||||
const nextApiPrerelease = checkPluginCompatibility(pluginManifest, {
|
||||
netcattyVersion: "1.4.0",
|
||||
apiVersion: "0.2.0-alpha.1",
|
||||
features: ["netcatty.rpc.progress"],
|
||||
});
|
||||
assert.equal(nextApiPrerelease.compatible, false);
|
||||
assert.match(nextApiPrerelease.errors.join("\n"), /plugin API version .* does not satisfy/);
|
||||
});
|
||||
|
||||
test("compatibility CLI checks a validated plugin target", async (context) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-compatibility-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const directory = await createPlugin(root);
|
||||
|
||||
const compatible = await execFileAsync(process.execPath, [
|
||||
"--import",
|
||||
"tsx",
|
||||
cliPath,
|
||||
"compatibility",
|
||||
directory,
|
||||
"--netcatty",
|
||||
"1.5.0",
|
||||
]);
|
||||
assert.match(compatible.stdout, /Compatible: com\.example\.package-test@1\.0\.0/);
|
||||
|
||||
await assert.rejects(
|
||||
execFileAsync(process.execPath, [
|
||||
"--import",
|
||||
"tsx",
|
||||
cliPath,
|
||||
"compatibility",
|
||||
directory,
|
||||
"--netcatty",
|
||||
"2.0.0",
|
||||
]),
|
||||
/Plugin is incompatible/,
|
||||
);
|
||||
});
|
||||
|
||||
test("example README commands use repository-root CLI paths", async () => {
|
||||
const readme = await readFile(
|
||||
path.join(repositoryRoot, "examples/plugins/hello-netcatty/README.md"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(readme, /npm run build:plugin-packages/);
|
||||
assert.match(
|
||||
readme,
|
||||
/npm exec -- netcatty-plugin validate examples\/plugins\/hello-netcatty/,
|
||||
);
|
||||
assert.match(
|
||||
readme,
|
||||
/npm exec -- netcatty-plugin compatibility examples\/plugins\/hello-netcatty --netcatty 0\.0\.0/,
|
||||
);
|
||||
assert.doesNotMatch(readme, /npm exec --workspace @netcatty\/plugin-cli/);
|
||||
});
|
||||
|
||||
test("init creates a valid TypeScript plugin skeleton", async (context) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-init-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const directory = path.join(root, "created");
|
||||
|
||||
await initPlugin(directory, { id: "com.example.created", name: "Created" });
|
||||
|
||||
const createdManifest = await readAndValidateManifest(directory);
|
||||
assert.equal(createdManifest.id, "com.example.created");
|
||||
assert.match(await readFile(path.join(directory, "src/index.ts"), "utf8"), /definePlugin/);
|
||||
});
|
||||
|
||||
test("init safely serializes the display name in generated TypeScript", async (context) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-init-escape-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const directory = path.join(root, "created");
|
||||
const displayName = 'A "quoted" \\ plugin\nnext line';
|
||||
|
||||
await initPlugin(directory, { id: "com.example.escaped", name: displayName });
|
||||
|
||||
const source = await readFile(path.join(directory, "src/index.ts"), "utf8");
|
||||
assert.ok(
|
||||
source.includes(`context.logger.info(${JSON.stringify(`${displayName} activated`)});`),
|
||||
);
|
||||
});
|
||||
|
||||
test("packing is deterministic and the archive validates", async (context) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-pack-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const directory = await createPlugin(root);
|
||||
const first = path.join(root, "first.ncpkg");
|
||||
const second = path.join(root, "second.ncpkg");
|
||||
|
||||
const firstResult = await buildPluginPackage(directory, first);
|
||||
await buildPluginPackage(directory, second);
|
||||
const firstBytes = await readFile(first);
|
||||
const secondBytes = await readFile(second);
|
||||
|
||||
assert.deepEqual(firstBytes, secondBytes);
|
||||
assert.equal(
|
||||
firstResult.sha256,
|
||||
createHash("sha256").update(firstBytes).digest("hex"),
|
||||
);
|
||||
const validation = await validatePluginPackage(first);
|
||||
const directoryValidation = await validatePluginDirectory(directory);
|
||||
assert.equal(validation.manifest.id, "com.example.package-test");
|
||||
assert.equal(validation.fileCount, 3);
|
||||
assert.equal(firstResult.contentSha256, validation.contentSha256);
|
||||
assert.equal(validation.contentSha256, directoryValidation.contentSha256);
|
||||
assert.match(validation.contentSha256, /^[a-f0-9]{64}$/u);
|
||||
});
|
||||
|
||||
test("logical content identity follows companion declarations across ZIP mode encoders", async (context) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-companion-mode-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const directory = await createPlugin(root);
|
||||
const companionContents = Buffer.from("portable companion\n");
|
||||
const companionPath = path.join(directory, "bin/helper");
|
||||
await mkdir(path.dirname(companionPath), { recursive: true });
|
||||
await writeFile(companionPath, companionContents);
|
||||
await writeFile(
|
||||
path.join(directory, "netcatty.plugin.json"),
|
||||
`${JSON.stringify(manifest({
|
||||
main: { browser: "dist/index.js", node: "dist/index.js" },
|
||||
permissions: {
|
||||
required: [
|
||||
"runtime.advanced",
|
||||
{
|
||||
permission: "companion.execute",
|
||||
resources: ["com.example.package-test.helper"],
|
||||
},
|
||||
],
|
||||
},
|
||||
companionExecutables: [{
|
||||
id: "com.example.package-test.helper",
|
||||
variants: [{
|
||||
path: "bin/helper",
|
||||
platforms: ["linux-x64"],
|
||||
sha256: createHash("sha256").update(companionContents).digest("hex"),
|
||||
}],
|
||||
}],
|
||||
}), null, 2)}\n`,
|
||||
);
|
||||
const builtPath = path.join(root, "built.ncpkg");
|
||||
const portablePath = path.join(root, "portable.ncpkg");
|
||||
const extracted = path.join(root, "extracted");
|
||||
await buildPluginPackage(directory, builtPath);
|
||||
await writeFile(
|
||||
portablePath,
|
||||
replaceCentralEntryMode(await readFile(builtPath), "bin/helper", 0o100644),
|
||||
);
|
||||
|
||||
const archiveValidation = await extractPluginPackage(portablePath, extracted);
|
||||
const directoryValidation = await validatePluginDirectory(extracted, {
|
||||
allowIgnoredRootEntries: false,
|
||||
});
|
||||
assert.equal(archiveValidation.contentSha256, directoryValidation.contentSha256);
|
||||
});
|
||||
|
||||
test("validated extraction creates an isolated tree and removes partial output on failure", async (context) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-extract-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const directory = await createPlugin(root);
|
||||
const archive = path.join(root, "plugin.ncpkg");
|
||||
const extracted = path.join(root, "extracted");
|
||||
await buildPluginPackage(directory, archive);
|
||||
|
||||
const result = await extractPluginPackage(archive, extracted);
|
||||
assert.equal(result.manifest.id, "com.example.package-test");
|
||||
assert.equal(
|
||||
await readFile(path.join(extracted, "dist/index.js"), "utf8"),
|
||||
"export default {};\n",
|
||||
);
|
||||
await assert.rejects(extractPluginPackage(archive, extracted));
|
||||
assert.equal(
|
||||
await readFile(path.join(extracted, "dist/index.js"), "utf8"),
|
||||
"export default {};\n",
|
||||
);
|
||||
|
||||
const invalidArchive = path.join(root, "invalid.ncpkg");
|
||||
const failedDestination = path.join(root, "failed-extraction");
|
||||
await writeFile(invalidArchive, "not a zip");
|
||||
await assert.rejects(extractPluginPackage(invalidArchive, failedDestination));
|
||||
await assert.rejects(readFile(failedDestination), /ENOENT/);
|
||||
});
|
||||
|
||||
test("archive validation rejects oversized manifests before buffering", async (context) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-archive-manifest-limit-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const oversizedPath = path.join(root, "oversized-manifest.ncpkg");
|
||||
const oversizedBytes = createZipEntry(
|
||||
"netcatty.plugin.json",
|
||||
Buffer.alloc(PACKAGE_LIMITS.manifestBytes + 1, 0x20),
|
||||
);
|
||||
await writeFile(oversizedPath, oversizedBytes);
|
||||
|
||||
await assert.rejects(
|
||||
validatePluginPackage(oversizedPath),
|
||||
new RegExp(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`),
|
||||
);
|
||||
|
||||
const forgedSizePath = path.join(root, "forged-size-manifest.ncpkg");
|
||||
const forgedSizeBytes = createZipEntry(
|
||||
"netcatty.plugin.json",
|
||||
Buffer.alloc(PACKAGE_LIMITS.manifestBytes + 1, 0x20),
|
||||
{
|
||||
compressionMethod: 8,
|
||||
declaredUncompressedSize: PACKAGE_LIMITS.manifestBytes,
|
||||
},
|
||||
);
|
||||
await writeFile(forgedSizePath, forgedSizeBytes);
|
||||
|
||||
await assert.rejects(
|
||||
validatePluginPackage(forgedSizePath),
|
||||
new RegExp(
|
||||
`too many bytes in the stream\\. expected ${PACKAGE_LIMITS.manifestBytes}\\. got at least ${PACKAGE_LIMITS.manifestBytes + 1}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("archive validation rejects duplicate names and CRC corruption", async (context) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-archive-safety-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const directory = await createPlugin(root);
|
||||
await Promise.all([
|
||||
writeFile(path.join(directory, "a.txt"), "first\n"),
|
||||
writeFile(path.join(directory, "b.txt"), "second\n"),
|
||||
]);
|
||||
await mkdir(path.join(directory, "bbbbb"));
|
||||
await Promise.all([
|
||||
writeFile(path.join(directory, "aaaaa"), "parent-file\n"),
|
||||
writeFile(path.join(directory, "bbbbb/file"), "child-file\n"),
|
||||
]);
|
||||
const validPath = path.join(root, "valid.ncpkg");
|
||||
await buildPluginPackage(directory, validPath);
|
||||
const validBytes = await readFile(validPath);
|
||||
|
||||
const duplicateBytes = Buffer.from(validBytes);
|
||||
const originalName = Buffer.from("b.txt");
|
||||
const duplicateName = Buffer.from("a.txt");
|
||||
let replacements = 0;
|
||||
for (let offset = duplicateBytes.indexOf(originalName); offset !== -1;) {
|
||||
duplicateName.copy(duplicateBytes, offset);
|
||||
replacements += 1;
|
||||
offset = duplicateBytes.indexOf(originalName, offset + originalName.byteLength);
|
||||
}
|
||||
assert.equal(replacements, 2, "ZIP should contain the local and central entry names");
|
||||
const duplicatePath = path.join(root, "duplicate.ncpkg");
|
||||
await writeFile(duplicatePath, duplicateBytes);
|
||||
await assert.rejects(validatePluginPackage(duplicatePath), /Duplicate or case-colliding/);
|
||||
|
||||
const prefixCollisionBytes = Buffer.from(validBytes);
|
||||
let prefixReplacements = 0;
|
||||
for (const [source, target] of [
|
||||
[Buffer.from("aaaaa"), Buffer.from("distx")],
|
||||
[Buffer.from("bbbbb/file"), Buffer.from("distx/file")],
|
||||
]) {
|
||||
for (let offset = prefixCollisionBytes.indexOf(source); offset !== -1;) {
|
||||
target.copy(prefixCollisionBytes, offset);
|
||||
prefixReplacements += 1;
|
||||
offset = prefixCollisionBytes.indexOf(source, offset + source.byteLength);
|
||||
}
|
||||
}
|
||||
assert.equal(prefixReplacements, 4, "ZIP should contain both local and central names");
|
||||
const prefixCollisionPath = path.join(root, "prefix-collision.ncpkg");
|
||||
await writeFile(prefixCollisionPath, prefixCollisionBytes);
|
||||
await assert.rejects(
|
||||
validatePluginPackage(prefixCollisionPath),
|
||||
/File\/directory package path collision/,
|
||||
);
|
||||
|
||||
const corruptedBytes = Buffer.from(validBytes);
|
||||
const content = Buffer.from("# Package test\n");
|
||||
const contentOffset = corruptedBytes.indexOf(content);
|
||||
assert.notEqual(contentOffset, -1);
|
||||
corruptedBytes[contentOffset] ^= 0x01;
|
||||
const corruptedPath = path.join(root, "corrupted.ncpkg");
|
||||
await writeFile(corruptedPath, corruptedBytes);
|
||||
await assert.rejects(validatePluginPackage(corruptedPath), /integrity check failed/);
|
||||
|
||||
const splitNameBytes = Buffer.from(validBytes);
|
||||
const localName = Buffer.from("README.md");
|
||||
const conflictingLocalName = Buffer.from("renamed.x");
|
||||
assert.equal(localName.byteLength, conflictingLocalName.byteLength);
|
||||
const localNameOffset = splitNameBytes.indexOf(localName);
|
||||
assert.notEqual(localNameOffset, -1);
|
||||
conflictingLocalName.copy(splitNameBytes, localNameOffset);
|
||||
const splitNamePath = path.join(root, "split-name.ncpkg");
|
||||
await writeFile(splitNamePath, splitNameBytes);
|
||||
await assert.rejects(
|
||||
validatePluginPackage(splitNamePath),
|
||||
/local and central entry names differ/,
|
||||
);
|
||||
});
|
||||
|
||||
test("packer rejects symbolic links and undeclared executables", async (context) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-safety-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const directory = await createPlugin(root);
|
||||
await assert.rejects(
|
||||
buildPluginPackage(directory, path.join(root, "wrong-extension.zip")),
|
||||
/\.ncpkg extension/,
|
||||
);
|
||||
if (process.platform !== "win32") {
|
||||
await symlink("README.md", path.join(directory, "linked-readme"));
|
||||
await assert.rejects(
|
||||
buildPluginPackage(directory, path.join(root, "symlink.ncpkg")),
|
||||
/Symbolic links/,
|
||||
);
|
||||
await rm(path.join(directory, "linked-readme"));
|
||||
}
|
||||
await mkdir(path.join(directory, "bin"));
|
||||
const executablePath = path.join(directory, "bin/tool.exe");
|
||||
await writeFile(executablePath, "not-a-real-executable\n");
|
||||
await assert.rejects(
|
||||
buildPluginPackage(directory, path.join(root, "executable.ncpkg")),
|
||||
/not declared as a companion/,
|
||||
);
|
||||
});
|
||||
|
||||
test("packer ignores root dev artifacts without dropping nested runtime dependencies", async (context) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-runtime-deps-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const directory = await createPlugin(root);
|
||||
await Promise.all([
|
||||
mkdir(path.join(directory, "node_modules/dev-only"), { recursive: true }),
|
||||
mkdir(path.join(directory, "dist/node_modules/runtime-dependency"), { recursive: true }),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeFile(path.join(directory, "node_modules/dev-only/index.js"), "dev only\n"),
|
||||
writeFile(
|
||||
path.join(directory, "dist/node_modules/runtime-dependency/index.js"),
|
||||
"export const runtime = true;\n",
|
||||
),
|
||||
]);
|
||||
const packagePath = path.join(root, "runtime-deps.ncpkg");
|
||||
|
||||
const build = await buildPluginPackage(directory, packagePath);
|
||||
const validation = await validatePluginPackage(packagePath);
|
||||
|
||||
assert.equal(build.fileCount, 4);
|
||||
assert.equal(validation.fileCount, 4);
|
||||
});
|
||||
|
||||
test("packer rejects outputs inside the plugin source tree", async (context) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-output-containment-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const directory = await createPlugin(root);
|
||||
const nestedOutput = path.join(directory, "dist/plugin.ncpkg");
|
||||
await writeFile(nestedOutput, "previous package output\n");
|
||||
|
||||
await assert.rejects(
|
||||
buildPluginPackage(directory, nestedOutput),
|
||||
/output must be outside the plugin source directory/,
|
||||
);
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const outputAlias = path.join(root, "output-alias");
|
||||
await symlink(path.join(directory, "dist"), outputAlias);
|
||||
await assert.rejects(
|
||||
buildPluginPackage(directory, path.join(outputAlias, "plugin.ncpkg")),
|
||||
/output must be outside the plugin source directory/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("manifest byte limit is enforced before JSON parsing", async (context) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-limit-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const manifestPath = path.join(root, "netcatty.plugin.json");
|
||||
await writeFile(manifestPath, "{}");
|
||||
await truncate(manifestPath, PACKAGE_LIMITS.manifestBytes * 4);
|
||||
await assert.rejects(readAndValidateManifest(root), /manifest exceeds/);
|
||||
});
|
||||
|
||||
test("manifest validation refuses symlinked source manifests", async (context) => {
|
||||
if (process.platform === "win32") return;
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-manifest-link-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const target = path.join(root, "target.json");
|
||||
await writeFile(target, JSON.stringify(manifest()));
|
||||
await symlink(target, path.join(root, "netcatty.plugin.json"));
|
||||
await assert.rejects(readAndValidateManifest(root), /must be a regular file/);
|
||||
});
|
||||
|
||||
test("validated manifest snapshots reject changed package bytes", () => {
|
||||
const snapshot = { size: 128, sha256: "a".repeat(64) };
|
||||
assert.doesNotThrow(() => assertManifestSnapshotMatches(snapshot, snapshot));
|
||||
assert.throws(
|
||||
() => assertManifestSnapshotMatches(snapshot, { ...snapshot, size: 129 }),
|
||||
/manifest changed after validation/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertManifestSnapshotMatches(snapshot, { ...snapshot, sha256: "b".repeat(64) }),
|
||||
/manifest changed after validation/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertManifestSnapshotMatches(snapshot, undefined),
|
||||
/manifest changed after validation/,
|
||||
);
|
||||
});
|
||||
|
||||
test("source hashing enforces its byte budget while reading", async (context) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-source-limit-"));
|
||||
context.after(() => rm(root, { recursive: true, force: true }));
|
||||
const filePath = path.join(root, "growing.bin");
|
||||
await writeFile(filePath, "1234");
|
||||
|
||||
await assert.rejects(hashFile(filePath, 3), /source exceeds 3 bytes while reading/);
|
||||
assert.equal((await hashFile(filePath, 4)).size, 4);
|
||||
});
|
||||
92
packages/plugin-cli/src/cli.ts
Normal file
92
packages/plugin-cli/src/cli.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import process from "node:process";
|
||||
|
||||
import { checkPluginCompatibility } from "./compatibility.js";
|
||||
import { buildPlugin, initPlugin, packPlugin, validateTarget } from "./commands.js";
|
||||
|
||||
const USAGE = `Netcatty plugin CLI (API 0.1.0-internal)
|
||||
|
||||
Usage:
|
||||
netcatty-plugin init <directory> --id <reverse.dns.id> [--name <display name>]
|
||||
netcatty-plugin validate <directory|package.ncpkg>
|
||||
netcatty-plugin compatibility <directory|package.ncpkg> --netcatty <version> [--api <version>] [--features <id,id,...>]
|
||||
netcatty-plugin build <directory>
|
||||
netcatty-plugin pack <directory> [--out <package.ncpkg>]
|
||||
`;
|
||||
|
||||
function optionValue(args: readonly string[], name: string): string | undefined {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) return undefined;
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${name}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function main(args: readonly string[]): Promise<void> {
|
||||
const [command, target] = args;
|
||||
if (!command || command === "help" || command === "--help" || command === "-h") {
|
||||
process.stdout.write(USAGE);
|
||||
return;
|
||||
}
|
||||
if (!target) throw new Error(`Missing target for ${command}`);
|
||||
|
||||
if (command === "init") {
|
||||
const id = optionValue(args, "--id");
|
||||
if (!id) throw new Error("init requires --id <reverse.dns.id>");
|
||||
const directory = await initPlugin(target, { id, name: optionValue(args, "--name") });
|
||||
process.stdout.write(`Initialized plugin in ${directory}\n`);
|
||||
return;
|
||||
}
|
||||
if (command === "validate") {
|
||||
const result = await validateTarget(target);
|
||||
process.stdout.write(
|
||||
`Valid ${result.kind}: ${result.manifest.id}@${result.manifest.version}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command === "compatibility") {
|
||||
const netcattyVersion = optionValue(args, "--netcatty");
|
||||
if (!netcattyVersion) {
|
||||
throw new Error("compatibility requires --netcatty <version>");
|
||||
}
|
||||
const targetResult = await validateTarget(target);
|
||||
const features = optionValue(args, "--features")
|
||||
?.split(",")
|
||||
.map((feature) => feature.trim())
|
||||
.filter(Boolean);
|
||||
const result = checkPluginCompatibility(targetResult.manifest, {
|
||||
netcattyVersion,
|
||||
apiVersion: optionValue(args, "--api"),
|
||||
features,
|
||||
});
|
||||
if (!result.compatible) {
|
||||
throw new Error(`Plugin is incompatible:\n- ${result.errors.join("\n- ")}`);
|
||||
}
|
||||
const featureSummary = result.enabledFeatures.length > 0
|
||||
? result.enabledFeatures.join(", ")
|
||||
: "none";
|
||||
process.stdout.write(
|
||||
`Compatible: ${targetResult.manifest.id}@${targetResult.manifest.version}\nEnabled features: ${featureSummary}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command === "build") {
|
||||
await buildPlugin(target);
|
||||
process.stdout.write("Plugin build completed.\n");
|
||||
return;
|
||||
}
|
||||
if (command === "pack") {
|
||||
const result = await packPlugin(target, optionValue(args, "--out"));
|
||||
process.stdout.write(
|
||||
`Packed ${result.fileCount} files to ${result.outputPath}\nSHA-256 ${result.sha256}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
|
||||
main(process.argv.slice(2)).catch((error: unknown) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
137
packages/plugin-cli/src/commands.ts
Normal file
137
packages/plugin-cli/src/commands.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { PluginManifest } from "@netcatty/plugin-contract";
|
||||
|
||||
import {
|
||||
buildPluginPackage,
|
||||
validatePluginDirectory,
|
||||
validatePluginPackage,
|
||||
} from "./archive.js";
|
||||
import { readAndValidateManifest } from "./manifest.js";
|
||||
|
||||
export interface InitPluginOptions {
|
||||
readonly id: string;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
export async function initPlugin(
|
||||
targetDirectory: string,
|
||||
options: InitPluginOptions,
|
||||
): Promise<string> {
|
||||
const directory = path.resolve(targetDirectory);
|
||||
await mkdir(directory, { recursive: true });
|
||||
const existingEntries = await readdir(directory);
|
||||
if (existingEntries.length > 0) {
|
||||
throw new Error(`Target directory is not empty: ${directory}`);
|
||||
}
|
||||
const displayName = options.name?.trim() || options.id.split(".").at(-1) || options.id;
|
||||
const activationMessage = JSON.stringify(`${displayName} activated`);
|
||||
const packageName = options.id.replaceAll(".", "-");
|
||||
const manifest: PluginManifest = {
|
||||
$schema: "https://netcatty.com/schemas/plugins/0.1.0-internal/plugin-contract.schema.json",
|
||||
manifestVersion: 1,
|
||||
id: options.id,
|
||||
name: displayName,
|
||||
displayName,
|
||||
description: "A Netcatty plugin",
|
||||
version: "0.1.0",
|
||||
publisher: options.id.split(".")[0] || "local",
|
||||
engines: {
|
||||
netcatty: ">=0.0.0",
|
||||
api: ">=0.1.0-internal <0.2.0",
|
||||
},
|
||||
main: { browser: "dist/index.js" },
|
||||
activationEvents: ["onStartupFinished"],
|
||||
permissions: { required: [], optional: [] },
|
||||
};
|
||||
await mkdir(path.join(directory, "src"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(directory, "netcatty.plugin.json"),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
),
|
||||
writeFile(
|
||||
path.join(directory, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: packageName,
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
scripts: { build: "tsc -p tsconfig.json" },
|
||||
dependencies: { "@netcatty/plugin-sdk": "0.1.0-internal" },
|
||||
devDependencies: { typescript: "^5.9.0" },
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
),
|
||||
writeFile(
|
||||
path.join(directory, "tsconfig.json"),
|
||||
`${JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: "ES2022",
|
||||
module: "ESNext",
|
||||
moduleResolution: "Bundler",
|
||||
strict: true,
|
||||
declaration: true,
|
||||
rootDir: "src",
|
||||
outDir: "dist",
|
||||
},
|
||||
include: ["src/**/*.ts"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
),
|
||||
writeFile(
|
||||
path.join(directory, "src/index.ts"),
|
||||
`import { definePlugin } from "@netcatty/plugin-sdk";\n\nexport default definePlugin({\n activate(context) {\n context.logger.info(${activationMessage});\n },\n});\n`,
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
await readAndValidateManifest(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
export async function validateTarget(target: string) {
|
||||
const resolved = path.resolve(target);
|
||||
const targetStats = await stat(resolved);
|
||||
if (targetStats.isDirectory()) {
|
||||
const result = await validatePluginDirectory(resolved);
|
||||
return { kind: "directory" as const, ...result };
|
||||
}
|
||||
if (targetStats.isFile() && resolved.endsWith(".ncpkg")) {
|
||||
const result = await validatePluginPackage(resolved);
|
||||
return { kind: "package" as const, ...result };
|
||||
}
|
||||
throw new Error("Validation target must be a plugin directory or .ncpkg file");
|
||||
}
|
||||
|
||||
export async function buildPlugin(pluginDirectory: string): Promise<void> {
|
||||
const directory = path.resolve(pluginDirectory);
|
||||
await readAndValidateManifest(directory);
|
||||
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(npmCommand, ["run", "build", "--if-present"], {
|
||||
cwd: directory,
|
||||
env: process.env,
|
||||
shell: false,
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
});
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Plugin build failed (${signal ?? `exit ${String(code)}`})`));
|
||||
});
|
||||
});
|
||||
await validatePluginDirectory(directory);
|
||||
}
|
||||
|
||||
export async function packPlugin(pluginDirectory: string, outputPath?: string) {
|
||||
const directory = path.resolve(pluginDirectory);
|
||||
const manifest = await readAndValidateManifest(directory);
|
||||
const resolvedOutput = outputPath
|
||||
? path.resolve(outputPath)
|
||||
: path.join(path.dirname(directory), `${manifest.id}-${manifest.version}.ncpkg`);
|
||||
return buildPluginPackage(directory, resolvedOutput);
|
||||
}
|
||||
72
packages/plugin-cli/src/compatibility.ts
Normal file
72
packages/plugin-cli/src/compatibility.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
type PluginManifest,
|
||||
} from "@netcatty/plugin-contract";
|
||||
import { satisfies, valid, validRange } from "semver";
|
||||
|
||||
const DEFAULT_PLUGIN_API_VERSION = "0.1.0-internal";
|
||||
|
||||
export interface PluginCompatibilityTarget {
|
||||
readonly netcattyVersion: string;
|
||||
readonly apiVersion?: string;
|
||||
readonly features?: readonly string[];
|
||||
}
|
||||
|
||||
export interface PluginCompatibilityResult {
|
||||
readonly compatible: boolean;
|
||||
readonly apiVersion: string;
|
||||
readonly enabledFeatures: readonly string[];
|
||||
readonly missingRequiredFeatures: readonly string[];
|
||||
readonly errors: readonly string[];
|
||||
}
|
||||
|
||||
function checkEngineVersion(
|
||||
label: string,
|
||||
version: string,
|
||||
range: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
if (valid(version) === null) {
|
||||
errors.push(`Host ${label} version is not valid semver: ${version}`);
|
||||
return;
|
||||
}
|
||||
const normalizedRange = validRange(range);
|
||||
if (normalizedRange === null) {
|
||||
errors.push(`Plugin ${label} range is not valid semver: ${range}`);
|
||||
return;
|
||||
}
|
||||
if (!satisfies(version, normalizedRange)) {
|
||||
errors.push(`Host ${label} version ${version} does not satisfy ${range}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function checkPluginCompatibility(
|
||||
manifest: PluginManifest,
|
||||
target: PluginCompatibilityTarget,
|
||||
): PluginCompatibilityResult {
|
||||
const apiVersion = target.apiVersion ?? DEFAULT_PLUGIN_API_VERSION;
|
||||
const errors: string[] = [];
|
||||
checkEngineVersion("Netcatty", target.netcattyVersion, manifest.engines.netcatty, errors);
|
||||
checkEngineVersion("plugin API", apiVersion, manifest.engines.api, errors);
|
||||
|
||||
const supportedFeatures = new Set(target.features ?? []);
|
||||
const requiredFeatures = manifest.features?.required ?? [];
|
||||
const optionalFeatures = manifest.features?.optional ?? [];
|
||||
const missingRequiredFeatures = requiredFeatures
|
||||
.filter((feature) => !supportedFeatures.has(feature))
|
||||
.sort((left, right) => left.localeCompare(right, "en"));
|
||||
if (missingRequiredFeatures.length > 0) {
|
||||
errors.push(`Missing required features: ${missingRequiredFeatures.join(", ")}`);
|
||||
}
|
||||
|
||||
const enabledFeatures = [...new Set([...requiredFeatures, ...optionalFeatures])]
|
||||
.filter((feature) => supportedFeatures.has(feature))
|
||||
.sort((left, right) => left.localeCompare(right, "en"));
|
||||
|
||||
return {
|
||||
compatible: errors.length === 0,
|
||||
apiVersion,
|
||||
enabledFeatures,
|
||||
missingRequiredFeatures,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
15
packages/plugin-cli/src/constants.ts
Normal file
15
packages/plugin-cli/src/constants.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const PACKAGE_LIMITS = Object.freeze({
|
||||
archiveBytes: 100 * 1024 * 1024,
|
||||
uncompressedBytes: 250 * 1024 * 1024,
|
||||
singleFileBytes: 50 * 1024 * 1024,
|
||||
manifestBytes: 1024 * 1024,
|
||||
fileCount: 5_000,
|
||||
pathCharacters: 128,
|
||||
pathBytes: 512,
|
||||
});
|
||||
|
||||
export const IGNORED_ROOT_ENTRIES = new Set([
|
||||
".DS_Store",
|
||||
".git",
|
||||
"node_modules",
|
||||
]);
|
||||
28
packages/plugin-cli/src/index.ts
Normal file
28
packages/plugin-cli/src/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export { PACKAGE_LIMITS } from "./constants.js";
|
||||
export {
|
||||
buildPluginPackage,
|
||||
extractPluginPackage,
|
||||
validatePluginDirectory,
|
||||
validatePluginPackage,
|
||||
type PackageBuildResult,
|
||||
type PackageValidationResult,
|
||||
type PluginDirectoryValidationResult,
|
||||
} from "./archive.js";
|
||||
export {
|
||||
buildPlugin,
|
||||
initPlugin,
|
||||
packPlugin,
|
||||
validateTarget,
|
||||
type InitPluginOptions,
|
||||
} from "./commands.js";
|
||||
export {
|
||||
checkPluginCompatibility,
|
||||
type PluginCompatibilityResult,
|
||||
type PluginCompatibilityTarget,
|
||||
} from "./compatibility.js";
|
||||
export {
|
||||
readAndValidateManifest,
|
||||
validateManifestValue,
|
||||
type ManifestValidationResult,
|
||||
} from "./manifest.js";
|
||||
export { assertSafePackagePath, PackagePathRegistry } from "./packagePath.js";
|
||||
804
packages/plugin-cli/src/manifest.ts
Normal file
804
packages/plugin-cli/src/manifest.ts
Normal file
@@ -0,0 +1,804 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
lstat,
|
||||
open,
|
||||
readFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type {
|
||||
IconReference,
|
||||
PermissionDeclaration,
|
||||
PluginManifest,
|
||||
PluginPermission,
|
||||
} from "@netcatty/plugin-contract";
|
||||
import Ajv2020, { type ErrorObject } from "ajv/dist/2020.js";
|
||||
import addFormats from "ajv-formats";
|
||||
import { valid, validRange } from "semver";
|
||||
|
||||
import { PACKAGE_LIMITS } from "./constants.js";
|
||||
import { assertSafePackagePath } from "./packagePath.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const schemaPath = require.resolve(
|
||||
"@netcatty/plugin-contract/schema/plugin-contract.schema.json",
|
||||
);
|
||||
interface PluginContractSchema extends Record<string, unknown> {
|
||||
readonly $defs: {
|
||||
readonly JsonValueLimits: {
|
||||
readonly const: {
|
||||
readonly maxDepth: number;
|
||||
readonly maxNodes: number;
|
||||
};
|
||||
};
|
||||
readonly ResourceScopedPermission: {
|
||||
readonly enum: readonly PluginPermission[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const contractSchema = JSON.parse(
|
||||
await readFile(schemaPath, "utf8"),
|
||||
) as PluginContractSchema;
|
||||
const manifestJsonLimits = contractSchema.$defs.JsonValueLimits.const;
|
||||
if (!Number.isSafeInteger(manifestJsonLimits.maxDepth) || manifestJsonLimits.maxDepth < 1
|
||||
|| !Number.isSafeInteger(manifestJsonLimits.maxNodes) || manifestJsonLimits.maxNodes < 1) {
|
||||
throw new Error("Plugin contract JSON value limits are invalid");
|
||||
}
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: true });
|
||||
addFormats(ajv);
|
||||
const validateSchema = ajv.compile<PluginManifest>(contractSchema);
|
||||
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
const MAX_SETTING_PATTERN_LENGTH = 512;
|
||||
const MAX_RESTRICTED_SCHEMA_DEPTH = 8;
|
||||
const MAX_RESTRICTED_SCHEMA_NODES = 256;
|
||||
const RESTRICTED_SCHEMA_TYPES = new Set(["array", "boolean", "integer", "null", "number", "object", "string"]);
|
||||
const RESTRICTED_SCHEMA_KEYWORDS = new Set([
|
||||
"additionalProperties", "const", "enum", "items", "maxItems", "maxLength", "maximum",
|
||||
"minItems", "minLength", "minimum", "properties", "required", "type",
|
||||
]);
|
||||
const FORBIDDEN_SCHEMA_PROPERTY_NAMES = new Set(["__proto__", "constructor", "prototype"]);
|
||||
|
||||
export interface ManifestValidationResult {
|
||||
readonly valid: boolean;
|
||||
readonly manifest?: PluginManifest;
|
||||
readonly errors: readonly string[];
|
||||
}
|
||||
|
||||
export interface ValidatedManifestSource {
|
||||
readonly manifest: PluginManifest;
|
||||
readonly size: number;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
function formatAjvError(error: ErrorObject): string {
|
||||
const location = error.instancePath || "/";
|
||||
return `${location} ${error.message ?? "is invalid"}`;
|
||||
}
|
||||
|
||||
function plainRecord(value: unknown): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
function settingPatternError(source: string): string | null {
|
||||
if (source.length < 1 || source.length > MAX_SETTING_PATTERN_LENGTH
|
||||
|| /\(\?/u.test(source) || /\\(?:[1-9]|k<)/u.test(source)
|
||||
|| /\)(?:[*+?]|\{\d+(?:,\d*)?\})/u.test(source)) {
|
||||
return "pattern uses an unsafe regular-expression feature";
|
||||
}
|
||||
try { new RegExp(source, "u"); }
|
||||
catch { return "pattern is invalid"; }
|
||||
return null;
|
||||
}
|
||||
|
||||
function restrictedSettingSchemaErrors(root: unknown, rootType: "array" | null = "array"): string[] {
|
||||
const errors: string[] = [];
|
||||
const stack: Array<{ schema: unknown; depth: number }> = [{ schema: root, depth: 0 }];
|
||||
let nodes = 0;
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current || !plainRecord(current.schema)) {
|
||||
errors.push("valueSchema must contain plain schema objects");
|
||||
continue;
|
||||
}
|
||||
nodes += 1;
|
||||
if (nodes > MAX_RESTRICTED_SCHEMA_NODES || current.depth > MAX_RESTRICTED_SCHEMA_DEPTH) {
|
||||
errors.push("valueSchema is too complex");
|
||||
break;
|
||||
}
|
||||
const schema = current.schema;
|
||||
for (const keyword of Object.keys(schema)) {
|
||||
if (!RESTRICTED_SCHEMA_KEYWORDS.has(keyword)) errors.push(`valueSchema keyword is not allowed: ${keyword}`);
|
||||
}
|
||||
if (!RESTRICTED_SCHEMA_TYPES.has(schema.type as string)) {
|
||||
errors.push("every valueSchema node must declare one supported type");
|
||||
}
|
||||
if (current.depth === 0 && rootType && schema.type !== rootType) {
|
||||
errors.push(`valueSchema root must use type ${rootType}`);
|
||||
}
|
||||
if (schema.type === "array") {
|
||||
if (!plainRecord(schema.items)) errors.push("array valueSchema nodes require one items schema");
|
||||
else stack.push({ schema: schema.items, depth: current.depth + 1 });
|
||||
} else if (schema.items !== undefined || schema.minItems !== undefined || schema.maxItems !== undefined) {
|
||||
errors.push("valueSchema array keywords require type array");
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
if (!plainRecord(schema.properties)) errors.push("object valueSchema nodes require properties");
|
||||
else {
|
||||
for (const [name, child] of Object.entries(schema.properties)) {
|
||||
if (name.length < 1 || name.length > 128 || name.includes("\0")
|
||||
|| FORBIDDEN_SCHEMA_PROPERTY_NAMES.has(name)) {
|
||||
errors.push(`valueSchema property name is invalid: ${name}`);
|
||||
}
|
||||
stack.push({ schema: child, depth: current.depth + 1 });
|
||||
}
|
||||
if (schema.required !== undefined && (!Array.isArray(schema.required)
|
||||
|| new Set(schema.required).size !== schema.required.length
|
||||
|| schema.required.some((name) => typeof name !== "string" || !Object.hasOwn(schema.properties as object, name)))) {
|
||||
errors.push("valueSchema required fields are invalid");
|
||||
}
|
||||
}
|
||||
if (schema.additionalProperties !== false) errors.push("object valueSchema nodes must deny additionalProperties");
|
||||
} else if (schema.properties !== undefined || schema.required !== undefined || schema.additionalProperties !== undefined) {
|
||||
errors.push("valueSchema object keywords require type object");
|
||||
}
|
||||
for (const name of ["minItems", "maxItems", "minLength", "maxLength"] as const) {
|
||||
if (schema[name] !== undefined && (!Number.isSafeInteger(schema[name]) || (schema[name] as number) < 0)) {
|
||||
errors.push(`valueSchema ${name} must be a non-negative safe integer`);
|
||||
}
|
||||
}
|
||||
for (const name of ["minimum", "maximum"] as const) {
|
||||
if (schema[name] !== undefined && (typeof schema[name] !== "number" || !Number.isFinite(schema[name]))) {
|
||||
errors.push(`valueSchema ${name} must be finite`);
|
||||
}
|
||||
}
|
||||
if (typeof schema.minItems === "number" && typeof schema.maxItems === "number" && schema.minItems > schema.maxItems) {
|
||||
errors.push("valueSchema minItems must not exceed maxItems");
|
||||
}
|
||||
if (typeof schema.minLength === "number" && typeof schema.maxLength === "number" && schema.minLength > schema.maxLength) {
|
||||
errors.push("valueSchema minLength must not exceed maxLength");
|
||||
}
|
||||
if (typeof schema.minimum === "number" && typeof schema.maximum === "number" && schema.minimum > schema.maximum) {
|
||||
errors.push("valueSchema minimum must not exceed maximum");
|
||||
}
|
||||
if (schema.type === "integer" && typeof schema.minimum === "number" && typeof schema.maximum === "number"
|
||||
&& Math.ceil(schema.minimum) > Math.floor(schema.maximum)) {
|
||||
errors.push("valueSchema integer range must contain a valid integer");
|
||||
}
|
||||
if (schema.type !== "string" && (schema.minLength !== undefined || schema.maxLength !== undefined)) {
|
||||
errors.push("valueSchema string keywords require type string");
|
||||
}
|
||||
if (!["integer", "number"].includes(String(schema.type))
|
||||
&& (schema.minimum !== undefined || schema.maximum !== undefined)) {
|
||||
errors.push("valueSchema numeric keywords require type number or integer");
|
||||
}
|
||||
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length < 1 || schema.enum.length > 256)) {
|
||||
errors.push("valueSchema enum is invalid");
|
||||
}
|
||||
}
|
||||
return [...new Set(errors)];
|
||||
}
|
||||
|
||||
function assertManifestJsonStructure(root: unknown): void {
|
||||
const stack: Array<{ value: unknown; depth: number }> = [{ value: root, depth: 0 }];
|
||||
const seen = new WeakSet<object>();
|
||||
let nodes = 0;
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current) break;
|
||||
if (current.depth > manifestJsonLimits.maxDepth) {
|
||||
throw new RangeError(
|
||||
`JSON values must not exceed ${manifestJsonLimits.maxDepth} levels of nesting`,
|
||||
);
|
||||
}
|
||||
nodes += 1;
|
||||
if (nodes > manifestJsonLimits.maxNodes) {
|
||||
throw new RangeError(
|
||||
`JSON values must not contain more than ${manifestJsonLimits.maxNodes} nodes`,
|
||||
);
|
||||
}
|
||||
const { value } = current;
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") continue;
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new TypeError("JSON numbers must be finite");
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
throw new TypeError(`Unsupported JSON value type: ${typeof value}`);
|
||||
}
|
||||
if (seen.has(value)) throw new TypeError("JSON values must not contain shared references");
|
||||
seen.add(value);
|
||||
const nextDepth = current.depth + 1;
|
||||
if (Array.isArray(value)) {
|
||||
const keys = Object.keys(value);
|
||||
const ownKeys = Reflect.ownKeys(value);
|
||||
if (keys.length !== value.length || ownKeys.length !== value.length + 1) {
|
||||
throw new TypeError("JSON arrays must be dense and contain no named properties");
|
||||
}
|
||||
for (let index = value.length - 1; index >= 0; index -= 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
||||
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
||||
throw new TypeError("JSON arrays must contain enumerable data properties only");
|
||||
}
|
||||
stack.push({ value: descriptor.value, depth: nextDepth });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError("JSON objects must be plain records");
|
||||
}
|
||||
const stringKeys = Object.keys(value);
|
||||
const ownKeys = Reflect.ownKeys(value);
|
||||
if (ownKeys.length !== stringKeys.length) {
|
||||
throw new TypeError("JSON objects must not contain symbols or non-enumerable properties");
|
||||
}
|
||||
for (let index = stringKeys.length - 1; index >= 0; index -= 1) {
|
||||
const key = stringKeys[index];
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
throw new TypeError("JSON objects must not contain accessor properties");
|
||||
}
|
||||
stack.push({ value: descriptor.value, depth: nextDepth });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function permissionName(declaration: PermissionDeclaration): PluginPermission {
|
||||
return typeof declaration === "string" ? declaration : declaration.permission;
|
||||
}
|
||||
|
||||
function permissionResourceLimit(permission: PluginPermission): number {
|
||||
if (permission === "filesystem.read" || permission === "filesystem.write") return 8_192;
|
||||
if (permission === "companion.execute") return 192;
|
||||
return 2_048;
|
||||
}
|
||||
|
||||
const resourceScopedPermissions = new Set<PluginPermission>(
|
||||
contractSchema.$defs.ResourceScopedPermission.enum,
|
||||
);
|
||||
|
||||
function validateRequiredPermissionBounds(value: unknown): string[] {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
||||
const permissions = (value as { permissions?: unknown }).permissions;
|
||||
if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return [];
|
||||
const required = (permissions as { required?: unknown }).required;
|
||||
if (!Array.isArray(required)) return [];
|
||||
return required.flatMap((declaration) => {
|
||||
if (typeof declaration === "string") {
|
||||
return resourceScopedPermissions.has(declaration as PluginPermission)
|
||||
? [`Required permission ${declaration} must declare explicit resources`]
|
||||
: [];
|
||||
}
|
||||
if (!declaration || typeof declaration !== "object" || Array.isArray(declaration)) return [];
|
||||
const permission = (declaration as { permission?: unknown }).permission;
|
||||
const resources = (declaration as { resources?: unknown }).resources;
|
||||
return typeof permission === "string"
|
||||
&& resourceScopedPermissions.has(permission as PluginPermission)
|
||||
&& Array.isArray(resources)
|
||||
&& resources.includes("*")
|
||||
? [`Required permission ${permission} must not use the wildcard resource`]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
function validatePermissionResourceLimits(manifest: PluginManifest): string[] {
|
||||
const errors: string[] = [];
|
||||
for (const declaration of [
|
||||
...(manifest.permissions?.required ?? []),
|
||||
...(manifest.permissions?.optional ?? []),
|
||||
]) {
|
||||
if (typeof declaration === "string") continue;
|
||||
const maximum = permissionResourceLimit(declaration.permission);
|
||||
for (const resource of declaration.resources) {
|
||||
if (resource.length > maximum) {
|
||||
errors.push(
|
||||
`Permission resource for ${declaration.permission} exceeds ${maximum} characters`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function findDuplicateIds(manifest: PluginManifest): string[] {
|
||||
const errors: string[] = [];
|
||||
const groups = [
|
||||
["settings", manifest.contributes?.settings],
|
||||
["commands", manifest.contributes?.commands],
|
||||
["views", manifest.contributes?.views],
|
||||
["providers", manifest.contributes?.providers],
|
||||
["companionExecutables", manifest.companionExecutables],
|
||||
] as const;
|
||||
const ids = new Map<string, string>();
|
||||
for (const [groupName, contributions] of groups) {
|
||||
for (const contribution of contributions ?? []) {
|
||||
const previousGroup = ids.get(contribution.id);
|
||||
if (previousGroup) {
|
||||
errors.push(
|
||||
`Duplicate contribution id across ${previousGroup} and ${groupName}: ${contribution.id}`,
|
||||
);
|
||||
} else {
|
||||
ids.set(contribution.id, groupName);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateOwnedContributionIds(manifest: PluginManifest): string[] {
|
||||
const errors: string[] = [];
|
||||
const expectedPrefix = `${manifest.id}.`;
|
||||
const groups = [
|
||||
["setting", manifest.contributes?.settings],
|
||||
["command", manifest.contributes?.commands],
|
||||
["view", manifest.contributes?.views],
|
||||
["provider", manifest.contributes?.providers],
|
||||
["companion executable", manifest.companionExecutables],
|
||||
] as const;
|
||||
for (const [kind, contributions] of groups) {
|
||||
for (const contribution of contributions ?? []) {
|
||||
if (!contribution.id.startsWith(expectedPrefix)) {
|
||||
errors.push(
|
||||
`${kind} id must start with the owning plugin id '${expectedPrefix}': ${contribution.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateSemantics(manifest: PluginManifest): string[] {
|
||||
const errors = [
|
||||
...findDuplicateIds(manifest),
|
||||
...validateOwnedContributionIds(manifest),
|
||||
...validatePermissionResourceLimits(manifest),
|
||||
];
|
||||
for (const [engine, range] of Object.entries(manifest.engines)) {
|
||||
if (validRange(range) === null) {
|
||||
errors.push(`Invalid ${engine} engine semver range: ${range}`);
|
||||
}
|
||||
}
|
||||
if (valid(manifest.version) === null) {
|
||||
errors.push(`Invalid plugin semantic version: ${manifest.version}`);
|
||||
}
|
||||
|
||||
const requiredFeatures = new Set(manifest.features?.required ?? []);
|
||||
for (const feature of manifest.features?.optional ?? []) {
|
||||
if (requiredFeatures.has(feature)) {
|
||||
errors.push(`Feature cannot be both required and optional: ${feature}`);
|
||||
}
|
||||
}
|
||||
|
||||
const companionPaths = new Set<string>();
|
||||
for (const companion of manifest.companionExecutables ?? []) {
|
||||
const companionPlatforms = new Set<string>();
|
||||
for (const variant of companion.variants) {
|
||||
if (companionPaths.has(variant.path)) {
|
||||
errors.push(`Duplicate companion executable path: ${variant.path}`);
|
||||
}
|
||||
companionPaths.add(variant.path);
|
||||
for (const platform of variant.platforms) {
|
||||
if (companionPlatforms.has(platform)) {
|
||||
errors.push(`Duplicate companion platform for ${companion.id}: ${platform}`);
|
||||
}
|
||||
companionPlatforms.add(platform);
|
||||
}
|
||||
}
|
||||
}
|
||||
const requiredPermissions = new Set(
|
||||
(manifest.permissions?.required ?? []).map(permissionName),
|
||||
);
|
||||
if (requiredPermissions.size !== (manifest.permissions?.required ?? []).length) {
|
||||
errors.push("Required permissions must not contain duplicate permission names");
|
||||
}
|
||||
const optionalPermissions = new Set(
|
||||
(manifest.permissions?.optional ?? []).map(permissionName),
|
||||
);
|
||||
if (optionalPermissions.size !== (manifest.permissions?.optional ?? []).length) {
|
||||
errors.push("Optional permissions must not contain duplicate permission names");
|
||||
}
|
||||
const declaredPermissions = new Set([
|
||||
...requiredPermissions,
|
||||
...optionalPermissions,
|
||||
]);
|
||||
for (const declaration of manifest.permissions?.optional ?? []) {
|
||||
const permission = permissionName(declaration);
|
||||
if (requiredPermissions.has(permission)) {
|
||||
errors.push(`Permission cannot be both required and optional: ${permission}`);
|
||||
}
|
||||
}
|
||||
|
||||
const requirePermission = (condition: boolean, permission: PluginPermission, reason: string) => {
|
||||
if (condition && !declaredPermissions.has(permission)) {
|
||||
errors.push(`${reason} requires declared permission: ${permission}`);
|
||||
}
|
||||
};
|
||||
requirePermission(
|
||||
Boolean(manifest.contributes?.settings?.length),
|
||||
"settings.read",
|
||||
"Setting contributions",
|
||||
);
|
||||
requirePermission(
|
||||
Boolean(manifest.main.node),
|
||||
"runtime.advanced",
|
||||
"Node utility entrypoints",
|
||||
);
|
||||
requirePermission(
|
||||
Boolean(manifest.contributes?.commands?.length),
|
||||
"commands",
|
||||
"Command contributions",
|
||||
);
|
||||
requirePermission(Boolean(manifest.contributes?.menus?.length), "menus", "Menu contributions");
|
||||
requirePermission(Boolean(manifest.contributes?.views?.length), "views", "View contributions");
|
||||
requirePermission(
|
||||
Boolean(manifest.companionExecutables?.length),
|
||||
"companion.execute",
|
||||
"Companion executables",
|
||||
);
|
||||
requirePermission(
|
||||
Boolean(manifest.companionExecutables?.length),
|
||||
"runtime.advanced",
|
||||
"Companion executables",
|
||||
);
|
||||
if (manifest.companionExecutables?.length && !manifest.main.node) {
|
||||
errors.push("Companion executables require a Node utility entrypoint");
|
||||
}
|
||||
const providerPermissions = new Map<string, readonly PluginPermission[]>([
|
||||
["terminal.completion", ["provider.terminal", "terminal.complete"]],
|
||||
["terminal.decoration", ["provider.terminal", "terminal.output", "terminal.decorate"]],
|
||||
["terminal.link", ["provider.terminal", "terminal.output", "terminal.decorate"]],
|
||||
["terminal.hover", ["provider.terminal", "terminal.output", "terminal.decorate"]],
|
||||
["terminal.matcher", ["provider.terminal", "terminal.output", "terminal.decorate"]],
|
||||
["terminal.semantic", ["provider.terminal", "terminal.input", "terminal.decorate"]],
|
||||
["terminal.prompt", ["provider.terminal", "terminal.output", "terminal.decorate"]],
|
||||
["terminal.background", ["provider.terminal", "terminal.decorate"]],
|
||||
["terminal.theme", ["provider.terminal", "terminal.decorate"]],
|
||||
["terminal.interceptor.input", ["provider.terminal", "terminal.intercept.input"]],
|
||||
["terminal.interceptor.output", ["provider.terminal", "terminal.intercept.output"]],
|
||||
["connection", ["provider.connection"]],
|
||||
["authentication", ["provider.authentication"]],
|
||||
["sync", ["provider.sync"]],
|
||||
["importer", ["provider.importer"]],
|
||||
]);
|
||||
for (const provider of manifest.contributes?.providers ?? []) {
|
||||
for (const permission of providerPermissions.get(provider.kind) ?? ["provider.terminal"]) {
|
||||
requirePermission(true, permission, `Provider ${provider.id}`);
|
||||
}
|
||||
if (provider.kind === "terminal.interceptor.input"
|
||||
|| provider.kind === "terminal.interceptor.output") {
|
||||
requirePermission(true, "runtime.advanced", `Provider ${provider.id}`);
|
||||
if (!manifest.main.node) {
|
||||
errors.push(`Provider ${provider.id} requires a Node utility entrypoint`);
|
||||
}
|
||||
}
|
||||
if (provider.configurationSchema !== undefined) {
|
||||
const schemaErrors = restrictedSettingSchemaErrors(provider.configurationSchema, null);
|
||||
for (const error of schemaErrors) {
|
||||
errors.push(`Provider ${provider.id} configurationSchema ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const companion of manifest.companionExecutables ?? []) {
|
||||
for (const permission of companion.permissions ?? []) {
|
||||
requirePermission(
|
||||
true,
|
||||
permission,
|
||||
`Companion executable ${companion.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const commandIds = new Set((manifest.contributes?.commands ?? []).map(({ id }) => id));
|
||||
const viewIds = new Set((manifest.contributes?.views ?? []).map(({ id }) => id));
|
||||
const providerIds = new Set((manifest.contributes?.providers ?? []).map(({ id }) => id));
|
||||
for (const menu of manifest.contributes?.menus ?? []) {
|
||||
if (!commandIds.has(menu.command)) {
|
||||
errors.push(`Menu references an undeclared command: ${menu.command}`);
|
||||
}
|
||||
if (menu.alt && !commandIds.has(menu.alt)) {
|
||||
errors.push(`Menu references an undeclared alternate command: ${menu.alt}`);
|
||||
}
|
||||
}
|
||||
for (const keybinding of manifest.contributes?.keybindings ?? []) {
|
||||
if (!commandIds.has(keybinding.command)) {
|
||||
errors.push(`Keybinding references an undeclared command: ${keybinding.command}`);
|
||||
}
|
||||
}
|
||||
for (const activationEvent of manifest.activationEvents ?? []) {
|
||||
const targets = [
|
||||
["onCommand:", "command", commandIds],
|
||||
["onView:", "view", viewIds],
|
||||
["onProvider:", "provider", providerIds],
|
||||
] as const;
|
||||
for (const [prefix, kind, ids] of targets) {
|
||||
if (activationEvent.startsWith(prefix)) {
|
||||
const id = activationEvent.slice(prefix.length);
|
||||
if (!ids.has(id)) errors.push(`Activation event references an undeclared ${kind}: ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const setting of manifest.contributes?.settings ?? []) {
|
||||
if (setting.secret && setting.default !== undefined) {
|
||||
errors.push(`Secret setting must not declare a default value: ${setting.id}`);
|
||||
}
|
||||
if (["radio", "select", "multiselect"].includes(setting.control)
|
||||
&& !setting.options?.length) {
|
||||
errors.push(`${setting.control} setting requires options: ${setting.id}`);
|
||||
}
|
||||
const optionValues = new Set<string>();
|
||||
for (const option of setting.options ?? []) {
|
||||
if (optionValues.has(option.value)) {
|
||||
errors.push(`${setting.control} setting has duplicate option value '${option.value}': ${setting.id}`);
|
||||
}
|
||||
optionValues.add(option.value);
|
||||
}
|
||||
if (!["radio", "select", "multiselect"].includes(setting.control)
|
||||
&& setting.options !== undefined) {
|
||||
errors.push(`${setting.control} setting must not declare options: ${setting.id}`);
|
||||
}
|
||||
if (["number", "slider"].includes(setting.control) && setting.minimum !== undefined
|
||||
&& setting.maximum !== undefined && setting.minimum > setting.maximum) {
|
||||
errors.push(`${setting.control} setting minimum exceeds maximum: ${setting.id}`);
|
||||
}
|
||||
if (setting.control === "slider"
|
||||
&& (setting.minimum === undefined || setting.maximum === undefined)) {
|
||||
errors.push(`slider setting requires minimum and maximum: ${setting.id}`);
|
||||
}
|
||||
if (!["number", "slider"].includes(setting.control)
|
||||
&& (setting.minimum !== undefined
|
||||
|| setting.maximum !== undefined
|
||||
|| setting.step !== undefined)) {
|
||||
errors.push(`${setting.control} setting must not declare numeric bounds: ${setting.id}`);
|
||||
}
|
||||
if (setting.pattern !== undefined
|
||||
&& !["text", "textarea", "password"].includes(setting.control)) {
|
||||
errors.push(`${setting.control} setting must not declare a text pattern: ${setting.id}`);
|
||||
}
|
||||
if (setting.pattern !== undefined) {
|
||||
const patternError = settingPatternError(setting.pattern);
|
||||
if (patternError) errors.push(`${setting.control} setting ${patternError}: ${setting.id}`);
|
||||
else if (typeof setting.default === "string"
|
||||
&& !new RegExp(setting.pattern, "u").test(setting.default)) {
|
||||
errors.push(`${setting.control} setting default does not match its pattern: ${setting.id}`);
|
||||
}
|
||||
}
|
||||
if (setting.control === "password" && !setting.secret) {
|
||||
errors.push(`password setting must be marked secret: ${setting.id}`);
|
||||
}
|
||||
if (setting.secret && setting.control !== "password") {
|
||||
errors.push(`Secret setting must use the password control: ${setting.id}`);
|
||||
}
|
||||
if (setting.secret && setting.sync) {
|
||||
errors.push(`Secret setting must not be cloud-synced: ${setting.id}`);
|
||||
}
|
||||
if (setting.sync && ["device", "session"].includes(setting.scope)) {
|
||||
errors.push(`${setting.scope}-scoped setting must not be cloud-synced: ${setting.id}`);
|
||||
}
|
||||
if (setting.sync && ["file", "directory"].includes(setting.control)) {
|
||||
errors.push(`${setting.control} setting paths must not be cloud-synced: ${setting.id}`);
|
||||
}
|
||||
if (["list", "table"].includes(setting.control) && setting.valueSchema === undefined) {
|
||||
errors.push(`${setting.control} setting requires valueSchema: ${setting.id}`);
|
||||
}
|
||||
if (["list", "table"].includes(setting.control) && setting.valueSchema !== undefined) {
|
||||
const schemaErrors = restrictedSettingSchemaErrors(setting.valueSchema);
|
||||
for (const error of schemaErrors) errors.push(`${setting.control} setting ${error}: ${setting.id}`);
|
||||
if (schemaErrors.length === 0) {
|
||||
try {
|
||||
const validateDefault = ajv.compile(setting.valueSchema as object);
|
||||
if (setting.default !== undefined && !validateDefault(setting.default)) {
|
||||
errors.push(`${setting.control} setting default does not match valueSchema: ${setting.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${setting.control} setting valueSchema is invalid: ${setting.id}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!["list", "table"].includes(setting.control) && setting.valueSchema !== undefined) {
|
||||
errors.push(`${setting.control} setting must not declare valueSchema: ${setting.id}`);
|
||||
}
|
||||
if (!["list", "table"].includes(setting.control) && setting.sortable) {
|
||||
errors.push(`${setting.control} setting must not be sortable: ${setting.id}`);
|
||||
}
|
||||
if (setting.default !== undefined) {
|
||||
const stringControls = [
|
||||
"radio",
|
||||
"select",
|
||||
"text",
|
||||
"textarea",
|
||||
"password",
|
||||
"color",
|
||||
"font",
|
||||
"file",
|
||||
"directory",
|
||||
"keybinding",
|
||||
];
|
||||
if (setting.control === "switch" && typeof setting.default !== "boolean") {
|
||||
errors.push(`switch setting default must be boolean: ${setting.id}`);
|
||||
}
|
||||
if (stringControls.includes(setting.control) && typeof setting.default !== "string") {
|
||||
errors.push(`${setting.control} setting default must be a string: ${setting.id}`);
|
||||
}
|
||||
if (["number", "slider"].includes(setting.control)
|
||||
&& (typeof setting.default !== "number" || !Number.isFinite(setting.default))) {
|
||||
errors.push(`${setting.control} setting default must be a finite number: ${setting.id}`);
|
||||
}
|
||||
if (["list", "table"].includes(setting.control) && !Array.isArray(setting.default)) {
|
||||
errors.push(`${setting.control} setting default must be an array: ${setting.id}`);
|
||||
}
|
||||
if (setting.control === "multiselect") {
|
||||
if (!Array.isArray(setting.default)
|
||||
|| setting.default.some((value) => typeof value !== "string")) {
|
||||
errors.push(`multiselect setting default must be a string array: ${setting.id}`);
|
||||
} else {
|
||||
const selected = new Set<string>();
|
||||
for (const value of setting.default as string[]) {
|
||||
if (!optionValues.has(value)) {
|
||||
errors.push(`multiselect setting default uses an undeclared option '${value}': ${setting.id}`);
|
||||
}
|
||||
if (selected.has(value)) {
|
||||
errors.push(`multiselect setting default repeats option '${value}': ${setting.id}`);
|
||||
}
|
||||
selected.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (["radio", "select"].includes(setting.control)
|
||||
&& typeof setting.default === "string"
|
||||
&& !optionValues.has(setting.default)) {
|
||||
errors.push(`${setting.control} setting default uses an undeclared option '${setting.default}': ${setting.id}`);
|
||||
}
|
||||
if (typeof setting.default === "number" && Number.isFinite(setting.default)) {
|
||||
if (setting.minimum !== undefined && setting.default < setting.minimum) {
|
||||
errors.push(`${setting.control} setting default is below minimum: ${setting.id}`);
|
||||
}
|
||||
if (setting.maximum !== undefined && setting.default > setting.maximum) {
|
||||
errors.push(`${setting.control} setting default is above maximum: ${setting.id}`);
|
||||
}
|
||||
if (setting.step !== undefined) {
|
||||
const offset = setting.default - (setting.minimum ?? 0);
|
||||
const steps = offset / setting.step;
|
||||
if (Math.abs(steps - Math.round(steps)) > 1e-9) {
|
||||
errors.push(`${setting.control} setting default does not align to step: ${setting.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entryPath of [
|
||||
manifest.main.browser,
|
||||
manifest.main.node,
|
||||
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
|
||||
...(manifest.contributes?.commands ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
...(manifest.contributes?.menus ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
...(manifest.contributes?.views ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
|
||||
...(manifest.companionExecutables ?? []).flatMap(({ variants }) => (
|
||||
variants.map(({ path: companionPath }) => companionPath)
|
||||
)),
|
||||
]) {
|
||||
if (entryPath) {
|
||||
try {
|
||||
assertSafePackagePath(entryPath);
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function packageIconPaths(icon: IconReference | undefined): string[] {
|
||||
if (icon?.kind !== "package") return [];
|
||||
return icon.dark
|
||||
? [icon.light, icon.dark]
|
||||
: [icon.light];
|
||||
}
|
||||
|
||||
export function validateManifestValue(value: unknown): ManifestValidationResult {
|
||||
try {
|
||||
assertManifestJsonStructure(value);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [error instanceof Error ? error.message : String(error)],
|
||||
};
|
||||
}
|
||||
const requiredPermissionErrors = validateRequiredPermissionBounds(value);
|
||||
if (requiredPermissionErrors.length > 0) {
|
||||
return { valid: false, errors: requiredPermissionErrors };
|
||||
}
|
||||
if (!validateSchema(value)) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: (validateSchema.errors ?? []).map(formatAjvError),
|
||||
};
|
||||
}
|
||||
const semanticErrors = validateSemantics(value);
|
||||
return semanticErrors.length === 0
|
||||
? { valid: true, manifest: value, errors: [] }
|
||||
: { valid: false, errors: semanticErrors };
|
||||
}
|
||||
|
||||
export function parseAndValidateManifestContents(contents: Uint8Array): PluginManifest {
|
||||
if (contents.byteLength > PACKAGE_LIMITS.manifestBytes) {
|
||||
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(utf8Decoder.decode(contents));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Plugin manifest is not valid UTF-8 JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
const result = validateManifestValue(value);
|
||||
if (!result.valid || !result.manifest) {
|
||||
throw new Error(`Plugin manifest is invalid:\n- ${result.errors.join("\n- ")}`);
|
||||
}
|
||||
return result.manifest;
|
||||
}
|
||||
|
||||
export async function readValidatedManifestSource(
|
||||
pluginDirectory: string,
|
||||
): Promise<ValidatedManifestSource> {
|
||||
const manifestPath = path.join(pluginDirectory, "netcatty.plugin.json");
|
||||
const initialStats = await lstat(manifestPath);
|
||||
if (!initialStats.isFile()) {
|
||||
throw new Error("Plugin manifest must be a regular file");
|
||||
}
|
||||
if (initialStats.size > PACKAGE_LIMITS.manifestBytes) {
|
||||
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
|
||||
}
|
||||
const handle = await open(manifestPath, "r");
|
||||
let contents: Uint8Array;
|
||||
try {
|
||||
const [openedStats, currentStats] = await Promise.all([
|
||||
handle.stat(),
|
||||
lstat(manifestPath),
|
||||
]);
|
||||
if (!openedStats.isFile() || !currentStats.isFile()) {
|
||||
throw new Error("Plugin manifest must be a regular file");
|
||||
}
|
||||
if (openedStats.dev !== currentStats.dev || openedStats.ino !== currentStats.ino) {
|
||||
throw new Error("Plugin manifest changed while being opened");
|
||||
}
|
||||
if (openedStats.size > PACKAGE_LIMITS.manifestBytes) {
|
||||
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
|
||||
}
|
||||
const buffer = new Uint8Array(PACKAGE_LIMITS.manifestBytes + 1);
|
||||
let bytesRead = 0;
|
||||
while (bytesRead < buffer.byteLength) {
|
||||
const result = await handle.read(
|
||||
buffer,
|
||||
bytesRead,
|
||||
buffer.byteLength - bytesRead,
|
||||
null,
|
||||
);
|
||||
if (result.bytesRead === 0) break;
|
||||
bytesRead += result.bytesRead;
|
||||
}
|
||||
if (bytesRead > PACKAGE_LIMITS.manifestBytes) {
|
||||
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
|
||||
}
|
||||
contents = buffer.subarray(0, bytesRead);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return {
|
||||
manifest: parseAndValidateManifestContents(contents),
|
||||
size: contents.byteLength,
|
||||
sha256: createHash("sha256").update(contents).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readAndValidateManifest(
|
||||
pluginDirectory: string,
|
||||
): Promise<PluginManifest> {
|
||||
return (await readValidatedManifestSource(pluginDirectory)).manifest;
|
||||
}
|
||||
102
packages/plugin-cli/src/packagePath.ts
Normal file
102
packages/plugin-cli/src/packagePath.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { PACKAGE_LIMITS } from "./constants.js";
|
||||
|
||||
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
||||
const WINDOWS_SPECIAL = /[<>:"|?*]/;
|
||||
|
||||
function containsControlCharacter(value: string): boolean {
|
||||
return [...value].some((character) => character.charCodeAt(0) <= 0x1f);
|
||||
}
|
||||
|
||||
function portablePathKey(value: string): string {
|
||||
return value
|
||||
.normalize("NFKC")
|
||||
.toUpperCase()
|
||||
.toLowerCase()
|
||||
.normalize("NFKC");
|
||||
}
|
||||
|
||||
function assertPortablePathSyntax(value: string, originalInput: string): string[] {
|
||||
if (value.startsWith("/") || /^[A-Za-z]:/.test(value) || value.includes("\\")) {
|
||||
throw new Error(`Package path must be relative POSIX syntax: ${originalInput}`);
|
||||
}
|
||||
const segments = value.split("/");
|
||||
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
||||
throw new Error(`Package path contains an unsafe segment: ${originalInput}`);
|
||||
}
|
||||
for (const segment of segments) {
|
||||
if (
|
||||
segment.endsWith(".")
|
||||
|| segment.endsWith(" ")
|
||||
|| WINDOWS_RESERVED_NAME.test(segment)
|
||||
|| WINDOWS_SPECIAL.test(segment)
|
||||
|| containsControlCharacter(segment)
|
||||
) {
|
||||
throw new Error(`Package path is not portable across supported platforms: ${originalInput}`);
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
export function assertSafePackagePath(input: string): string {
|
||||
if (!input || input !== input.normalize("NFC")) {
|
||||
throw new Error(`Package path must be non-empty NFC text: ${JSON.stringify(input)}`);
|
||||
}
|
||||
if ([...input].length > PACKAGE_LIMITS.pathCharacters) {
|
||||
throw new Error(
|
||||
`Package path exceeds ${PACKAGE_LIMITS.pathCharacters} Unicode characters: ${input}`,
|
||||
);
|
||||
}
|
||||
if (Buffer.byteLength(input, "utf8") > PACKAGE_LIMITS.pathBytes) {
|
||||
throw new Error(`Package path exceeds ${PACKAGE_LIMITS.pathBytes} UTF-8 bytes: ${input}`);
|
||||
}
|
||||
const rawSegments = assertPortablePathSyntax(input, input);
|
||||
const compatibilitySegments = assertPortablePathSyntax(input.normalize("NFKC"), input);
|
||||
if (compatibilitySegments.length !== rawSegments.length) {
|
||||
throw new Error(
|
||||
`Package path changes directory structure after Unicode compatibility normalization: ${input}`,
|
||||
);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export class PackagePathRegistry {
|
||||
readonly #filesExact = new Set<string>();
|
||||
readonly #filesPortable = new Set<string>();
|
||||
readonly #directoriesExact = new Set<string>();
|
||||
readonly #directoriesPortable = new Set<string>();
|
||||
|
||||
add(input: string): string {
|
||||
const safePath = assertSafePackagePath(input);
|
||||
const portableKey = portablePathKey(safePath);
|
||||
if (this.#filesExact.has(safePath) || this.#filesPortable.has(portableKey)) {
|
||||
throw new Error(`Duplicate or case-colliding package path: ${safePath}`);
|
||||
}
|
||||
if (
|
||||
this.#directoriesExact.has(safePath)
|
||||
|| this.#directoriesPortable.has(portableKey)
|
||||
) {
|
||||
throw new Error(`File/directory package path collision: ${safePath}`);
|
||||
}
|
||||
|
||||
const segments = safePath.split("/");
|
||||
const ancestors: string[] = [];
|
||||
for (let index = 1; index < segments.length; index += 1) {
|
||||
const ancestor = segments.slice(0, index).join("/");
|
||||
if (
|
||||
this.#filesExact.has(ancestor)
|
||||
|| this.#filesPortable.has(portablePathKey(ancestor))
|
||||
) {
|
||||
throw new Error(`File/directory package path collision: ${safePath}`);
|
||||
}
|
||||
ancestors.push(ancestor);
|
||||
}
|
||||
|
||||
this.#filesExact.add(safePath);
|
||||
this.#filesPortable.add(portableKey);
|
||||
for (const ancestor of ancestors) {
|
||||
this.#directoriesExact.add(ancestor);
|
||||
this.#directoriesPortable.add(portablePathKey(ancestor));
|
||||
}
|
||||
return safePath;
|
||||
}
|
||||
}
|
||||
17
packages/plugin-cli/tsconfig.build.json
Normal file
17
packages/plugin-cli/tsconfig.build.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
25
packages/plugin-contract/package.json
Normal file
25
packages/plugin-contract/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@netcatty/plugin-contract",
|
||||
"version": "0.1.0-internal",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"files": [
|
||||
"dist",
|
||||
"schema"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./schema/plugin-contract.schema.json": "./schema/plugin-contract.schema.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ajv": "8.18.0",
|
||||
"ajv-formats": "3.0.1"
|
||||
}
|
||||
}
|
||||
2131
packages/plugin-contract/schema/plugin-contract.schema.json
Normal file
2131
packages/plugin-contract/schema/plugin-contract.schema.json
Normal file
File diff suppressed because it is too large
Load Diff
1474
packages/plugin-contract/src/contract.test.ts
Normal file
1474
packages/plugin-contract/src/contract.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
// This file is generated from schema/plugin-contract.schema.json.
|
||||
// Run `npm run generate:plugin-contract` after changing the contract.
|
||||
// Do not edit this file directly.
|
||||
|
||||
export const PLUGIN_JSON_MAX_DEPTH = 128 as const;
|
||||
export const PLUGIN_JSON_MAX_NODES = 100000 as const;
|
||||
export const PLUGIN_WIRE_MAX_SAFE_INTEGER = 9007199254740991 as const;
|
||||
export const PLUGIN_RPC_MAX_JSON_BYTES = 1048576 as const;
|
||||
export const PLUGIN_RPC_ERROR_CODES = [-32700,-32600,-32601,-32602,-32603,-32001,-32002,-32003,-32004,-32005,-32006,-32007,-32008,-32009,-32010,-32011,-32012,-32013,-32014,-32015,-32016] as const;
|
||||
export const PLUGIN_STREAM_MAX_ID_LENGTH = 128 as const;
|
||||
export const PLUGIN_STREAM_MAX_CHUNK_BYTES = 16777216 as const;
|
||||
export const PLUGIN_STREAM_MAX_FRAME_JSON_BYTES = 25165824 as const;
|
||||
export const PLUGIN_STREAM_MIN_WINDOW_BYTES = 1024 as const;
|
||||
export const PLUGIN_STREAM_MAX_WINDOW_BYTES = 16777216 as const;
|
||||
export const PLUGIN_STREAM_MAX_CREDIT_BYTES = 16777216 as const;
|
||||
export const PLUGIN_TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES = 65536 as const;
|
||||
export const PLUGIN_TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES = 262144 as const;
|
||||
export const PLUGIN_IMPORTER_MAX_INPUT_BYTES = 67108864 as const;
|
||||
export const PLUGIN_IMPORTER_MAX_OUTPUT_BYTES = 67108864 as const;
|
||||
export const PLUGIN_IMPORTER_MAX_RECORD_BYTES = 67108864 as const;
|
||||
export const PLUGIN_IMPORTER_MAX_RECORDS = 10000 as const;
|
||||
export const PLUGIN_SYNC_MAX_OBJECT_BYTES = 67108864 as const;
|
||||
export const PLUGIN_SYNC_MAX_OBJECT_KEY_LENGTH = 1024 as const;
|
||||
export const PLUGIN_SYNC_MAX_REVISION_LENGTH = 256 as const;
|
||||
export const PLUGIN_SYNC_INLINE_OBJECT_BYTES = 92160 as const;
|
||||
951
packages/plugin-contract/src/generated/plugin-contract.ts
Normal file
951
packages/plugin-contract/src/generated/plugin-contract.ts
Normal file
@@ -0,0 +1,951 @@
|
||||
// This file is generated from schema/plugin-contract.schema.json.
|
||||
// Run `npm run generate:plugin-contract` after changing the contract.
|
||||
// Do not edit this file directly.
|
||||
|
||||
export type ActivationEvent = "onStartupFinished" | `onCommand:${ContributionId}` | `onView:${ContributionId}` | `onProvider:${ContributionId}`;
|
||||
|
||||
export type AuthenticationBeginPayload = {
|
||||
operationId: string;
|
||||
connectionProviderId: ContributionId;
|
||||
configuration: JsonValue;
|
||||
credential?: (SecretRef) | (CredentialRef) | (SecretLeaseRef);
|
||||
};
|
||||
|
||||
export type AuthenticationChallenge = ({
|
||||
id: string;
|
||||
kind: "text" | "password" | "otp";
|
||||
title: string;
|
||||
message?: string;
|
||||
placeholder?: string;
|
||||
}) | ({
|
||||
id: string;
|
||||
kind: "choice";
|
||||
title: string;
|
||||
message?: string;
|
||||
choices: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}>;
|
||||
multiple?: boolean;
|
||||
}) | ({
|
||||
id: string;
|
||||
kind: "confirmation";
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
}) | ({
|
||||
id: string;
|
||||
kind: "browser";
|
||||
title: string;
|
||||
url: string;
|
||||
callbackUri?: string;
|
||||
}) | ({
|
||||
id: string;
|
||||
kind: "deviceCode";
|
||||
title: string;
|
||||
verificationUri: string;
|
||||
userCode: string;
|
||||
expiresAt: number;
|
||||
intervalMs?: number;
|
||||
});
|
||||
|
||||
export type AuthenticationResponsePayload = {
|
||||
operationId: string;
|
||||
challengeId: string;
|
||||
response: (string) | (boolean) | (Array<string>) | (SecretLeaseRef);
|
||||
};
|
||||
|
||||
export type AuthenticationResult = ({
|
||||
status: "challenge";
|
||||
challenge: AuthenticationChallenge;
|
||||
}) | ({
|
||||
status: "authenticated";
|
||||
credential?: (SecretRef) | (CredentialRef);
|
||||
}) | ({
|
||||
status: "cancelled";
|
||||
message?: string;
|
||||
}) | ({
|
||||
status: "failed";
|
||||
message?: string;
|
||||
});
|
||||
|
||||
export type BoundedPermissionResource = string;
|
||||
|
||||
export type CommandContribution = {
|
||||
id: ContributionId;
|
||||
title: LocalizedText;
|
||||
category?: LocalizedText;
|
||||
description?: LocalizedText;
|
||||
icon?: IconReference;
|
||||
enablement?: ContextKeyExpression;
|
||||
};
|
||||
|
||||
export type CompanionExecutable = {
|
||||
id: ContributionId;
|
||||
variants: Array<CompanionExecutableVariant>;
|
||||
permissions?: Array<PluginPermission>;
|
||||
};
|
||||
|
||||
export type CompanionExecutableVariant = {
|
||||
path: RelativePackagePath;
|
||||
platforms: Array<CompanionPlatform>;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
export type CompanionPlatform = "darwin-arm64" | "darwin-x64" | "linux-arm64" | "linux-x64" | "win32-arm64" | "win32-x64";
|
||||
|
||||
export type ConnectionConfigurationPayload = {
|
||||
configuration: JsonValue;
|
||||
};
|
||||
|
||||
export type ConnectionControlPayload = {
|
||||
connectionId: string;
|
||||
operationId: string;
|
||||
};
|
||||
|
||||
export type ConnectionControlResult = null;
|
||||
|
||||
export type ConnectionOpenPayload = {
|
||||
configuration: JsonValue;
|
||||
operationId: string;
|
||||
columns: number;
|
||||
rows: number;
|
||||
inputStreamId: string;
|
||||
outputStreamId: string;
|
||||
windowBytes: number;
|
||||
credential?: (SecretRef) | (CredentialRef) | (SecretLeaseRef);
|
||||
authenticationProviderId?: ContributionId;
|
||||
};
|
||||
|
||||
export type ConnectionOpenResult = {
|
||||
connectionId: string;
|
||||
status: "connecting" | "connected";
|
||||
diagnostics?: Array<ProviderValidationIssue>;
|
||||
};
|
||||
|
||||
export type ConnectionProbeResult = {
|
||||
available: boolean;
|
||||
message?: string;
|
||||
capabilities?: { [key: string]: JsonValue };
|
||||
};
|
||||
|
||||
export type ConnectionResizePayload = {
|
||||
connectionId: string;
|
||||
operationId: string;
|
||||
columns: number;
|
||||
rows: number;
|
||||
};
|
||||
|
||||
export type ConnectionSignalPayload = {
|
||||
connectionId: string;
|
||||
operationId: string;
|
||||
signal: "interrupt" | "terminate" | "kill" | "eof" | "break";
|
||||
};
|
||||
|
||||
export type ConnectionStatusResult = {
|
||||
status: "connecting" | "connected" | "reconnecting" | "closed" | "error";
|
||||
message?: string;
|
||||
retryable?: boolean;
|
||||
diagnostics?: Array<ProviderValidationIssue>;
|
||||
};
|
||||
|
||||
export type ConnectionValidateResult = {
|
||||
valid: boolean;
|
||||
issues: Array<ProviderValidationIssue>;
|
||||
};
|
||||
|
||||
export type ContextKeyExpression = string;
|
||||
|
||||
export type ContributionId = string;
|
||||
|
||||
export type CredentialRef = {
|
||||
kind: "credential";
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type FeatureId = string;
|
||||
|
||||
export type IconReference = (ThemeIcon) | (PackageIcon);
|
||||
|
||||
export type ImporterDetectPayload = {
|
||||
fileName?: string;
|
||||
mediaType?: string;
|
||||
sample: {
|
||||
encoding: "base64";
|
||||
data: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ImporterDetectResult = {
|
||||
confidence: number;
|
||||
format?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type ImporterGroupDraft = string | { path: string; label?: string } | { path?: string; label: string };
|
||||
|
||||
export type ImporterHostDraft = ({
|
||||
id?: string;
|
||||
label?: string;
|
||||
username?: string;
|
||||
group?: string;
|
||||
tags?: Array<string>;
|
||||
os?: "linux" | "windows" | "macos";
|
||||
deviceType?: "general" | "network";
|
||||
identityId?: string;
|
||||
identityFileId?: string;
|
||||
telnetIdentityId?: string;
|
||||
notes?: string;
|
||||
theme?: string;
|
||||
sftpEncoding?: string;
|
||||
sftpFileProtocol?: "auto" | "sftp" | "scp";
|
||||
moshEnabled?: boolean;
|
||||
etEnabled?: boolean;
|
||||
telnetEnabled?: boolean;
|
||||
sftpSudo?: boolean;
|
||||
requiresMfa?: boolean;
|
||||
useSshAgent?: boolean;
|
||||
identitiesOnly?: boolean;
|
||||
agentForwarding?: boolean;
|
||||
x11Forwarding?: boolean;
|
||||
showLineTimestamps?: boolean;
|
||||
disableDynamicTabTitle?: boolean;
|
||||
pinned?: boolean;
|
||||
autoOpenSftpPanel?: boolean;
|
||||
sftpFollowTerminalCwd?: boolean;
|
||||
port?: number;
|
||||
telnetPort?: number;
|
||||
etPort?: number;
|
||||
keepaliveInterval?: number;
|
||||
keepaliveCountMax?: number;
|
||||
} & ({
|
||||
hostname: string;
|
||||
protocol?: "ssh" | "telnet" | "mosh" | "et" | "local" | "serial";
|
||||
pluginConnection?: never;
|
||||
} | {
|
||||
hostname?: string;
|
||||
protocol: PluginHostProtocol;
|
||||
pluginConnection: ImporterPluginConnectionDraft;
|
||||
}));
|
||||
|
||||
export type ImporterIdentityDraft = {
|
||||
id?: string;
|
||||
label: string;
|
||||
username: string;
|
||||
authMethod: "password" | "key" | "certificate";
|
||||
password?: string;
|
||||
keyId?: string;
|
||||
};
|
||||
|
||||
export type ImporterKeyDraft = ({
|
||||
id?: string;
|
||||
label: string;
|
||||
type: "RSA" | "ECDSA" | "ED25519";
|
||||
publicKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
category?: "key" | "certificate" | "identity";
|
||||
} & ({
|
||||
privateKey: string;
|
||||
filePath?: never;
|
||||
} | {
|
||||
privateKey?: never;
|
||||
filePath: string;
|
||||
}));
|
||||
|
||||
export type ImporterLimits = {"maxInputBytes":67108864,"maxOutputBytes":67108864,"maxRecordBytes":67108864,"maxRecords":10000};
|
||||
|
||||
export type ImporterParsePayload = {
|
||||
operationId: string;
|
||||
fileName?: string;
|
||||
mediaType?: string;
|
||||
inputStreamId: string;
|
||||
outputStreamId: string;
|
||||
windowBytes: number;
|
||||
options?: JsonValue;
|
||||
};
|
||||
|
||||
export type ImporterParseResult = {
|
||||
parsed: number;
|
||||
warnings: number;
|
||||
errors: number;
|
||||
};
|
||||
|
||||
export type ImporterPluginConnectionDraft = {
|
||||
providerId: ContributionId;
|
||||
configuration: JsonValue;
|
||||
authenticationProviderId?: ContributionId;
|
||||
credentialId?: string;
|
||||
};
|
||||
|
||||
export type ImporterRecord = ({
|
||||
type: "draft";
|
||||
draft: ({
|
||||
kind: "host";
|
||||
value: ImporterHostDraft;
|
||||
}) | ({
|
||||
kind: "identity";
|
||||
value: ImporterIdentityDraft;
|
||||
}) | ({
|
||||
kind: "key";
|
||||
value: ImporterKeyDraft;
|
||||
}) | ({
|
||||
kind: "snippet";
|
||||
value: ImporterSnippetDraft;
|
||||
}) | ({
|
||||
kind: "group";
|
||||
value: ImporterGroupDraft;
|
||||
});
|
||||
}) | ({
|
||||
type: "warning" | "error";
|
||||
code?: string;
|
||||
message: string;
|
||||
path?: string;
|
||||
}) | ({
|
||||
type: "progress";
|
||||
completed: number;
|
||||
total?: number;
|
||||
message?: string;
|
||||
});
|
||||
|
||||
export type ImporterSnippetDraft = {
|
||||
id?: string;
|
||||
label: string;
|
||||
command: string;
|
||||
tags?: Array<string>;
|
||||
kind?: "snippet" | "script";
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type JsonPrimitive = (string) | (number) | (boolean) | (null);
|
||||
|
||||
export type JsonRpcStandardErrorCode = -32700 | -32600 | -32601 | -32602 | -32603;
|
||||
|
||||
export type JsonValue = (JsonPrimitive) | (Array<JsonValue>) | ({ [key: string]: JsonValue });
|
||||
|
||||
export type JsonValueLimits = {"maxDepth":128,"maxNodes":100000};
|
||||
|
||||
export type KeybindingContribution = {
|
||||
command: ContributionId;
|
||||
key: string;
|
||||
mac?: string;
|
||||
linux?: string;
|
||||
windows?: string;
|
||||
when?: ContextKeyExpression;
|
||||
args?: JsonValue;
|
||||
};
|
||||
|
||||
export type LocalizedText = (string) | ({ [key: string]: string });
|
||||
|
||||
export type MenuContribution = {
|
||||
command: ContributionId;
|
||||
alt?: ContributionId;
|
||||
location: MenuLocation;
|
||||
title?: LocalizedText;
|
||||
icon?: IconReference;
|
||||
group?: string;
|
||||
order?: number;
|
||||
when?: ContextKeyExpression;
|
||||
enablement?: ContextKeyExpression;
|
||||
checked?: ContextKeyExpression;
|
||||
showKeybinding?: boolean;
|
||||
};
|
||||
|
||||
export type MenuLocation = "commandPalette" | "application" | "host/context" | "terminal/context" | "terminal/toolbar" | "statusBar";
|
||||
|
||||
export type NonResourceScopedPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "secrets" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
|
||||
export type NullableRpcId = (RpcId) | (null);
|
||||
|
||||
export type PackageIcon = {
|
||||
kind: "package";
|
||||
light: RelativePackagePath;
|
||||
dark?: RelativePackagePath;
|
||||
};
|
||||
|
||||
export type PermissionDecision = ({
|
||||
requestId: string;
|
||||
decision: "allow";
|
||||
scope: PermissionGrantScope;
|
||||
resources?: Array<PermissionResource>;
|
||||
}) | ({
|
||||
requestId: string;
|
||||
decision: "deny" | "cancel";
|
||||
});
|
||||
|
||||
export type PermissionDeclaration = (PluginPermission) | (PermissionResourceDeclaration);
|
||||
|
||||
export type PermissionGrantScope = "once" | "session" | "application" | "always";
|
||||
|
||||
export type PermissionRequest = {
|
||||
requestId: string;
|
||||
pluginId: PluginId;
|
||||
pluginVersion?: SemanticVersion;
|
||||
pluginName?: string;
|
||||
publisher?: string;
|
||||
runtimeId?: string | null;
|
||||
runtimeKind?: "browser" | "utility" | null;
|
||||
permission: PluginPermission;
|
||||
resources?: Array<PermissionResource>;
|
||||
resourceKinds?: Array<PermissionResourceKind>;
|
||||
reason: string;
|
||||
operationId?: string;
|
||||
sessionId?: string;
|
||||
allowedScopes?: Array<PermissionGrantScope>;
|
||||
};
|
||||
|
||||
export type PermissionResource = string;
|
||||
|
||||
export type PermissionResourceDeclaration = {
|
||||
permission: PluginPermission;
|
||||
resources: Array<PermissionResource>;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type PermissionResourceKind = "exact" | "directory";
|
||||
|
||||
export type PermissionSet = {
|
||||
required?: Array<RequiredPermissionDeclaration>;
|
||||
optional?: Array<PermissionDeclaration>;
|
||||
};
|
||||
|
||||
export type PluginContributions = {
|
||||
settings?: Array<SettingContribution>;
|
||||
commands?: Array<CommandContribution>;
|
||||
keybindings?: Array<KeybindingContribution>;
|
||||
menus?: Array<MenuContribution>;
|
||||
views?: Array<ViewContribution>;
|
||||
providers?: Array<ProviderContribution>;
|
||||
};
|
||||
|
||||
export type PluginEngineHeader = ({
|
||||
netcatty: SemverRange;
|
||||
api: SemverRange;
|
||||
} & Record<string, unknown>);
|
||||
|
||||
export type PluginEngines = {
|
||||
netcatty: SemverRange;
|
||||
api: SemverRange;
|
||||
};
|
||||
|
||||
export type PluginEntrypoints = {
|
||||
browser?: RelativePackagePath;
|
||||
node?: RelativePackagePath;
|
||||
};
|
||||
|
||||
export type PluginErrorData = {
|
||||
pluginCode: PluginErrorName;
|
||||
details?: JsonValue;
|
||||
};
|
||||
|
||||
export type PluginErrorName = "cancelled" | "unknown" | "deadline_exceeded" | "invalid_argument" | "not_found" | "already_exists" | "permission_denied" | "resource_exhausted" | "failed_precondition" | "aborted" | "out_of_range" | "unavailable" | "unsupported" | "internal" | "data_loss" | "unauthenticated";
|
||||
|
||||
export type PluginFeatures = {
|
||||
required?: Array<FeatureId>;
|
||||
optional?: Array<FeatureId>;
|
||||
};
|
||||
|
||||
export type PluginHostProtocol = `plugin:${ContributionId}`;
|
||||
|
||||
export type PluginId = string;
|
||||
|
||||
export type PluginManifest = {
|
||||
$schema?: string;
|
||||
manifestVersion: 1;
|
||||
id: PluginId;
|
||||
name: string;
|
||||
displayName?: LocalizedText;
|
||||
description?: LocalizedText;
|
||||
version: SemanticVersion;
|
||||
publisher: string;
|
||||
license?: string;
|
||||
homepage?: string;
|
||||
repository?: string;
|
||||
engines: PluginEngines;
|
||||
features?: PluginFeatures;
|
||||
main: PluginEntrypoints;
|
||||
activationEvents?: Array<ActivationEvent>;
|
||||
permissions?: PermissionSet;
|
||||
contributes?: PluginContributions;
|
||||
companionExecutables?: Array<CompanionExecutable>;
|
||||
};
|
||||
|
||||
export type PluginManifestHeader = ({
|
||||
$schema?: string;
|
||||
manifestVersion: number;
|
||||
id: PluginId;
|
||||
version: SemanticVersion;
|
||||
engines: PluginEngineHeader;
|
||||
} & Record<string, unknown>);
|
||||
|
||||
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
|
||||
export type PluginWireErrorCode = -32001 | -32002 | -32003 | -32004 | -32005 | -32006 | -32007 | -32008 | -32009 | -32010 | -32011 | -32012 | -32013 | -32014 | -32015 | -32016;
|
||||
|
||||
export type ProgressBegin = {
|
||||
kind: "begin";
|
||||
title: string;
|
||||
message?: string;
|
||||
percentage?: number;
|
||||
cancellable?: boolean;
|
||||
};
|
||||
|
||||
export type ProgressEnd = {
|
||||
kind: "end";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type ProgressReport = {
|
||||
kind: "report";
|
||||
message?: string;
|
||||
percentage?: number;
|
||||
increment?: number;
|
||||
};
|
||||
|
||||
export type ProgressToken = RpcId;
|
||||
|
||||
export type ProgressValue = (ProgressBegin) | (ProgressReport) | (ProgressEnd);
|
||||
|
||||
export type ProviderContribution = {
|
||||
id: ContributionId;
|
||||
label: LocalizedText;
|
||||
description?: LocalizedText;
|
||||
kind: ProviderKind;
|
||||
capabilities?: Array<FeatureId>;
|
||||
configurationSchema?: JsonValue;
|
||||
};
|
||||
|
||||
export type ProviderKind = "terminal.completion" | "terminal.decoration" | "terminal.link" | "terminal.hover" | "terminal.matcher" | "terminal.semantic" | "terminal.prompt" | "terminal.background" | "terminal.theme" | "terminal.interceptor.input" | "terminal.interceptor.output" | "connection" | "authentication" | "sync" | "importer";
|
||||
|
||||
export type ProviderRequest = {
|
||||
providerId: ContributionId;
|
||||
operation: string;
|
||||
requestId: string;
|
||||
payload?: JsonValue;
|
||||
deadlineMs?: number;
|
||||
cancellationId?: string;
|
||||
};
|
||||
|
||||
export type ProviderResult = ({
|
||||
requestId: string;
|
||||
status: "ok";
|
||||
result: JsonValue;
|
||||
}) | ({
|
||||
requestId: string;
|
||||
status: "cancelled";
|
||||
}) | ({
|
||||
requestId: string;
|
||||
status: "failed";
|
||||
error: RpcErrorObject;
|
||||
});
|
||||
|
||||
export type ProviderValidationIssue = {
|
||||
path?: string;
|
||||
severity: "warning" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type RelativePackagePath = string;
|
||||
|
||||
export type RequiredPermissionDeclaration = (NonResourceScopedPermission) | (RequiredPermissionResourceDeclaration);
|
||||
|
||||
export type RequiredPermissionResourceDeclaration = {
|
||||
permission: ResourceScopedPermission;
|
||||
resources: Array<BoundedPermissionResource>;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type ResourceScopedPermission = "network" | "filesystem.read" | "filesystem.write" | "companion.execute";
|
||||
|
||||
export type RpcCancel = {
|
||||
jsonrpc: "2.0";
|
||||
method: "$/cancelRequest";
|
||||
params: {
|
||||
cancellationId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type RpcErrorCode = (JsonRpcStandardErrorCode) | (PluginWireErrorCode);
|
||||
|
||||
export type RpcErrorObject = {
|
||||
code: RpcErrorCode;
|
||||
message: string;
|
||||
data?: JsonValue;
|
||||
};
|
||||
|
||||
export type RpcFailure = {
|
||||
jsonrpc: "2.0";
|
||||
id: NullableRpcId;
|
||||
error: RpcErrorObject;
|
||||
};
|
||||
|
||||
export type RpcId = (string) | (SafeUnsignedInteger);
|
||||
|
||||
export type RpcLimits = {"maxJsonBytes":1048576};
|
||||
|
||||
export type RpcMessage = (RpcRequest) | (RpcNotification) | (RpcSuccess) | (RpcFailure) | (RpcCancel) | (RpcProgressNotification) | (RuntimeInitializeRequest) | (TerminalInterceptorAttachmentRequest);
|
||||
|
||||
export type RpcNotification = {
|
||||
jsonrpc: "2.0";
|
||||
method: string;
|
||||
params?: JsonValue;
|
||||
};
|
||||
|
||||
export type RpcProgressNotification = {
|
||||
jsonrpc: "2.0";
|
||||
method: "$/progress";
|
||||
params: {
|
||||
token: ProgressToken;
|
||||
value: ProgressValue;
|
||||
};
|
||||
};
|
||||
|
||||
export type RpcRequest = {
|
||||
jsonrpc: "2.0";
|
||||
id: RpcId;
|
||||
method: string;
|
||||
params?: JsonValue;
|
||||
deadlineMs?: number;
|
||||
cancellationId?: string;
|
||||
};
|
||||
|
||||
export type RpcSuccess = {
|
||||
jsonrpc: "2.0";
|
||||
id: RpcId;
|
||||
result: JsonValue;
|
||||
};
|
||||
|
||||
export type RuntimeInitializeParams = {
|
||||
netcattyVersion: SemanticVersion;
|
||||
apiVersion: SemanticVersion;
|
||||
supportedFeatures: Array<FeatureId>;
|
||||
};
|
||||
|
||||
export type RuntimeInitializeRequest = {
|
||||
jsonrpc: "2.0";
|
||||
id: RpcId;
|
||||
method: "plugin.initialize";
|
||||
params: RuntimeInitializeParams;
|
||||
deadlineMs?: number;
|
||||
cancellationId?: string;
|
||||
};
|
||||
|
||||
export type RuntimeInitializeResult = {
|
||||
pluginId: PluginId;
|
||||
pluginVersion: SemanticVersion;
|
||||
apiVersion: SemanticVersion;
|
||||
enabledFeatures: Array<FeatureId>;
|
||||
};
|
||||
|
||||
export type RuntimeInitializeSuccess = {
|
||||
jsonrpc: "2.0";
|
||||
id: RpcId;
|
||||
result: RuntimeInitializeResult;
|
||||
};
|
||||
|
||||
export type SafePositiveInteger = number;
|
||||
|
||||
export type SafeUnsignedInteger = number;
|
||||
|
||||
export type SecretLeaseRef = {
|
||||
kind: "secret-lease";
|
||||
id: string;
|
||||
operationId: string;
|
||||
expiresAt: SafePositiveInteger;
|
||||
};
|
||||
|
||||
export type SecretRef = {
|
||||
kind: "secret";
|
||||
id: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type SemanticVersion = string;
|
||||
|
||||
export type SemverRange = string;
|
||||
|
||||
export type SettingContribution = {
|
||||
id: ContributionId;
|
||||
label: LocalizedText;
|
||||
description?: LocalizedText;
|
||||
control: SettingControl;
|
||||
scope: SettingScope;
|
||||
default?: JsonValue;
|
||||
secret?: boolean;
|
||||
required?: boolean;
|
||||
options?: Array<SettingOption>;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
step?: number;
|
||||
pattern?: string;
|
||||
placeholder?: LocalizedText;
|
||||
when?: ContextKeyExpression;
|
||||
restartRequired?: boolean;
|
||||
sync?: boolean;
|
||||
sortable?: boolean;
|
||||
valueSchema?: JsonValue;
|
||||
};
|
||||
|
||||
export type SettingControl = "switch" | "radio" | "select" | "multiselect" | "text" | "textarea" | "number" | "slider" | "password" | "color" | "font" | "file" | "directory" | "keybinding" | "list" | "table";
|
||||
|
||||
export type SettingOption = {
|
||||
value: string;
|
||||
label: LocalizedText;
|
||||
description?: LocalizedText;
|
||||
};
|
||||
|
||||
export type SettingScope = "application" | "workspace" | "host" | "session" | "device";
|
||||
|
||||
export type StreamChunkByteLength = number;
|
||||
|
||||
export type StreamChunkData = ({
|
||||
encoding: "json";
|
||||
value: JsonValue;
|
||||
byteLength: StreamChunkByteLength;
|
||||
}) | ({
|
||||
encoding: "base64";
|
||||
value: string;
|
||||
byteLength: StreamChunkByteLength;
|
||||
}) | ({
|
||||
encoding: "transfer";
|
||||
byteLength: StreamChunkByteLength;
|
||||
});
|
||||
|
||||
export type StreamCreditBytes = number;
|
||||
|
||||
export type StreamFrame = ({
|
||||
streamId: StreamId;
|
||||
sequence: 0;
|
||||
kind: "open";
|
||||
windowBytes: StreamWindowBytes;
|
||||
}) | ({
|
||||
streamId: StreamId;
|
||||
sequence: SafePositiveInteger;
|
||||
kind: "chunk";
|
||||
data: StreamChunkData;
|
||||
}) | ({
|
||||
streamId: StreamId;
|
||||
sequence: SafePositiveInteger;
|
||||
kind: "end" | "cancel";
|
||||
}) | ({
|
||||
streamId: StreamId;
|
||||
sequence: SafePositiveInteger;
|
||||
kind: "error";
|
||||
error: RpcErrorObject;
|
||||
}) | ({
|
||||
streamId: StreamId;
|
||||
sequence: SafeUnsignedInteger;
|
||||
kind: "windowUpdate";
|
||||
creditBytes: StreamCreditBytes;
|
||||
});
|
||||
|
||||
export type StreamId = string;
|
||||
|
||||
export type StreamLimits = {"maxStreamIdLength":128,"maxChunkBytes":16777216,"maxFrameJsonBytes":25165824,"minWindowBytes":1024,"maxWindowBytes":16777216,"maxCreditBytes":16777216};
|
||||
|
||||
export type StreamWindowBytes = number;
|
||||
|
||||
export type SyncAccount = {
|
||||
id: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
export type SyncCapabilitiesResult = {
|
||||
revisions: boolean;
|
||||
conditionalWrites: boolean;
|
||||
atomicReplacement: boolean;
|
||||
maxObjectBytes?: number;
|
||||
maxObjects?: number;
|
||||
};
|
||||
|
||||
export type SyncConnectPayload = {
|
||||
configuration: JsonValue;
|
||||
operationId: string;
|
||||
credential?: (SecretRef) | (CredentialRef) | (SecretLeaseRef);
|
||||
};
|
||||
|
||||
export type SyncConnectResult = {
|
||||
account: SyncAccount;
|
||||
};
|
||||
|
||||
export type SyncDeleteObjectPayload = {
|
||||
key: SyncObjectKey;
|
||||
operationId: string;
|
||||
expectedRevision?: SyncObjectRevision;
|
||||
};
|
||||
|
||||
export type SyncDeleteObjectResult = {
|
||||
deleted: boolean;
|
||||
};
|
||||
|
||||
export type SyncDisconnectPayload = {
|
||||
operationId?: string;
|
||||
};
|
||||
|
||||
export type SyncDisconnectResult = null;
|
||||
|
||||
export type SyncGetAccountPayload = {
|
||||
operationId?: string;
|
||||
};
|
||||
|
||||
export type SyncGetAccountResult = {
|
||||
account: (SyncAccount) | (null);
|
||||
};
|
||||
|
||||
export type SyncGetCapabilitiesPayload = {
|
||||
operationId?: string;
|
||||
};
|
||||
|
||||
export type SyncLimits = {"maxObjectBytes":67108864,"maxObjectKeyLength":1024,"maxRevisionLength":256,"inlineObjectBytes":92160};
|
||||
|
||||
export type SyncObjectKey = string;
|
||||
|
||||
export type SyncObjectRevision = string;
|
||||
|
||||
export type SyncReadObjectPayload = {
|
||||
key: SyncObjectKey;
|
||||
operationId: string;
|
||||
outputStreamId?: string;
|
||||
windowBytes?: number;
|
||||
};
|
||||
|
||||
export type SyncReadObjectResult = ({
|
||||
found: false;
|
||||
}) | ({
|
||||
found: true;
|
||||
byteLength: number;
|
||||
encoding: "base64";
|
||||
data: string;
|
||||
revision?: SyncObjectRevision;
|
||||
contentType?: string;
|
||||
}) | ({
|
||||
found: true;
|
||||
byteLength: number;
|
||||
streamed: true;
|
||||
revision?: SyncObjectRevision;
|
||||
contentType?: string;
|
||||
});
|
||||
|
||||
export type SyncWriteObjectPayload = {
|
||||
key: SyncObjectKey;
|
||||
operationId: string;
|
||||
byteLength: number;
|
||||
expectedRevision?: (SyncObjectRevision) | (null);
|
||||
encoding?: "base64";
|
||||
data?: string;
|
||||
inputStreamId?: string;
|
||||
windowBytes?: number;
|
||||
};
|
||||
|
||||
export type SyncWriteObjectResult = {
|
||||
created: boolean;
|
||||
revision?: SyncObjectRevision;
|
||||
};
|
||||
|
||||
export type TerminalInterceptorAttachmentDescriptor = {
|
||||
providerId: ContributionId;
|
||||
direction: "input" | "output";
|
||||
session: TerminalSessionSnapshot;
|
||||
};
|
||||
|
||||
export type TerminalInterceptorAttachmentParams = {
|
||||
descriptor: TerminalInterceptorAttachmentDescriptor;
|
||||
};
|
||||
|
||||
export type TerminalInterceptorAttachmentRequest = {
|
||||
jsonrpc: "2.0";
|
||||
id: RpcId;
|
||||
method: "plugin.terminal.interceptor.attach";
|
||||
params: TerminalInterceptorAttachmentParams;
|
||||
deadlineMs?: number;
|
||||
cancellationId?: string;
|
||||
};
|
||||
|
||||
export type TerminalInterceptorAttachmentResult = {
|
||||
accepted: true;
|
||||
};
|
||||
|
||||
export type TerminalInterceptorAttachmentSuccess = {
|
||||
jsonrpc: "2.0";
|
||||
id: RpcId;
|
||||
result: TerminalInterceptorAttachmentResult;
|
||||
};
|
||||
|
||||
export type TerminalInterceptorChunkByteLength = number;
|
||||
|
||||
export type TerminalInterceptorChunkFrame = {
|
||||
type: "netcatty:terminal-interceptor:chunk";
|
||||
sequence: SafePositiveInteger;
|
||||
direction: TerminalInterceptorDirection;
|
||||
creditBytes: TerminalInterceptorCreditBytes;
|
||||
byteLength: TerminalInterceptorChunkByteLength;
|
||||
};
|
||||
|
||||
export type TerminalInterceptorCreditBytes = number;
|
||||
|
||||
export type TerminalInterceptorDirection = "input" | "output";
|
||||
|
||||
export type TerminalInterceptorFailedResultFrame = {
|
||||
type: "netcatty:terminal-interceptor:result";
|
||||
sequence: SafePositiveInteger;
|
||||
status: "failed";
|
||||
};
|
||||
|
||||
export type TerminalInterceptorFrame = (TerminalInterceptorReadyFrame) | (TerminalInterceptorChunkFrame) | (TerminalInterceptorOkResultFrame) | (TerminalInterceptorFailedResultFrame);
|
||||
|
||||
export type TerminalInterceptorLimits = {"maxChunkBytes":65536,"maxWindowBytes":262144};
|
||||
|
||||
export type TerminalInterceptorOkResultFrame = {
|
||||
type: "netcatty:terminal-interceptor:result";
|
||||
sequence: SafePositiveInteger;
|
||||
status: "ok";
|
||||
creditBytes: TerminalInterceptorChunkByteLength;
|
||||
byteLength: TerminalInterceptorChunkByteLength;
|
||||
};
|
||||
|
||||
export type TerminalInterceptorReadyFrame = {
|
||||
type: "netcatty:terminal-interceptor:ready";
|
||||
sessionId: string;
|
||||
direction: TerminalInterceptorDirection;
|
||||
windowBytes: TerminalInterceptorWindowBytes;
|
||||
};
|
||||
|
||||
export type TerminalInterceptorWindowBytes = number;
|
||||
|
||||
export type TerminalSessionSnapshot = {
|
||||
sessionId: string;
|
||||
hostId?: string;
|
||||
workspaceId?: string;
|
||||
protocol: string;
|
||||
status: "connecting" | "connected" | "disconnected";
|
||||
cwd?: string;
|
||||
title?: string;
|
||||
shellType?: "posix" | "fish" | "powershell" | "cmd" | "unknown";
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
alternateScreen?: boolean;
|
||||
};
|
||||
|
||||
export type ThemeIcon = {
|
||||
kind: "theme";
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ViewContribution = {
|
||||
id: ContributionId;
|
||||
title: LocalizedText;
|
||||
location: ViewLocation;
|
||||
entry: RelativePackagePath;
|
||||
icon?: IconReference;
|
||||
order?: number;
|
||||
when?: ContextKeyExpression;
|
||||
retainContextWhenHidden?: boolean;
|
||||
};
|
||||
|
||||
export type ViewLocation = "aside" | "panel" | "tab" | "modal" | "settings";
|
||||
|
||||
export type WireIntegerLimits = {"maxSafeInteger":9007199254740991};
|
||||
49
packages/plugin-contract/src/index.ts
Normal file
49
packages/plugin-contract/src/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export const PLUGIN_API_VERSION = "0.1.0-internal" as const;
|
||||
export const PLUGIN_MANIFEST_FILE = "netcatty.plugin.json" as const;
|
||||
export const PLUGIN_PACKAGE_EXTENSION = ".ncpkg" as const;
|
||||
|
||||
export type * from "./generated/plugin-contract.js";
|
||||
export {
|
||||
PLUGIN_JSON_MAX_DEPTH,
|
||||
PLUGIN_JSON_MAX_NODES,
|
||||
assertJsonValue,
|
||||
serializeJsonValue,
|
||||
} from "./jsonValue.js";
|
||||
export {
|
||||
PLUGIN_IMPORTER_MAX_INPUT_BYTES,
|
||||
PLUGIN_IMPORTER_MAX_OUTPUT_BYTES,
|
||||
PLUGIN_IMPORTER_MAX_RECORD_BYTES,
|
||||
PLUGIN_IMPORTER_MAX_RECORDS,
|
||||
PLUGIN_RPC_ERROR_CODES,
|
||||
PLUGIN_RPC_MAX_JSON_BYTES,
|
||||
PLUGIN_SYNC_INLINE_OBJECT_BYTES,
|
||||
PLUGIN_SYNC_MAX_OBJECT_BYTES,
|
||||
PLUGIN_SYNC_MAX_OBJECT_KEY_LENGTH,
|
||||
PLUGIN_SYNC_MAX_REVISION_LENGTH,
|
||||
PLUGIN_TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES,
|
||||
PLUGIN_TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES,
|
||||
PLUGIN_WIRE_MAX_SAFE_INTEGER,
|
||||
} from "./generated/plugin-contract-limits.js";
|
||||
export {
|
||||
COMPANION_STDIO_MAX_CONTENT_BYTES,
|
||||
COMPANION_STDIO_MAX_HEADER_BYTES,
|
||||
ContentLengthFrameDecoder,
|
||||
encodeContentLengthFrame,
|
||||
type ContentLengthFrameDecoderOptions,
|
||||
} from "./stdioFraming.js";
|
||||
export {
|
||||
PLUGIN_STREAM_MAX_CHUNK_BYTES,
|
||||
PLUGIN_STREAM_MAX_CREDIT_BYTES,
|
||||
PLUGIN_STREAM_MAX_FRAME_JSON_BYTES,
|
||||
PLUGIN_STREAM_MAX_ID_LENGTH,
|
||||
PLUGIN_STREAM_MAX_WINDOW_BYTES,
|
||||
PLUGIN_STREAM_MIN_WINDOW_BYTES,
|
||||
assertStreamChunkData,
|
||||
assertStreamFrame,
|
||||
createBase64StreamChunk,
|
||||
createJsonStreamChunk,
|
||||
createMessagePortStreamEnvelope,
|
||||
materializeStreamChunk,
|
||||
type MaterializedStreamChunk,
|
||||
type MessagePortStreamEnvelope,
|
||||
} from "./streamTransport.js";
|
||||
149
packages/plugin-contract/src/jsonValue.ts
Normal file
149
packages/plugin-contract/src/jsonValue.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { JsonValue } from "./generated/plugin-contract.js";
|
||||
import {
|
||||
PLUGIN_JSON_MAX_DEPTH,
|
||||
PLUGIN_JSON_MAX_NODES,
|
||||
} from "./generated/plugin-contract-limits.js";
|
||||
|
||||
export {
|
||||
PLUGIN_JSON_MAX_DEPTH,
|
||||
PLUGIN_JSON_MAX_NODES,
|
||||
} from "./generated/plugin-contract-limits.js";
|
||||
|
||||
interface JsonValidationBudget {
|
||||
nodes: number;
|
||||
}
|
||||
|
||||
function assertJsonValueInternal(
|
||||
value: unknown,
|
||||
ancestors: WeakSet<object>,
|
||||
depth: number,
|
||||
budget: JsonValidationBudget,
|
||||
): void {
|
||||
if (depth > PLUGIN_JSON_MAX_DEPTH) {
|
||||
throw new RangeError(
|
||||
`JSON values must not exceed ${PLUGIN_JSON_MAX_DEPTH} levels of nesting`,
|
||||
);
|
||||
}
|
||||
budget.nodes += 1;
|
||||
if (budget.nodes > PLUGIN_JSON_MAX_NODES) {
|
||||
throw new RangeError(
|
||||
`JSON values must not contain more than ${PLUGIN_JSON_MAX_NODES} nodes`,
|
||||
);
|
||||
}
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new TypeError("JSON numbers must be finite");
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
throw new TypeError(`Unsupported JSON value type: ${typeof value}`);
|
||||
}
|
||||
if (ancestors.has(value)) throw new TypeError("JSON values must not contain cycles");
|
||||
ancestors.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
const keys = Object.keys(value);
|
||||
const ownKeys = Reflect.ownKeys(value);
|
||||
if (keys.length !== value.length || ownKeys.length !== value.length + 1) {
|
||||
throw new TypeError("JSON arrays must be dense and contain no named properties");
|
||||
}
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
||||
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
|
||||
throw new TypeError("JSON arrays must contain enumerable data properties only");
|
||||
}
|
||||
assertJsonValueInternal(descriptor.value, ancestors, depth + 1, budget);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError("JSON objects must be plain records");
|
||||
}
|
||||
const stringKeys = Object.keys(value);
|
||||
const ownKeys = Reflect.ownKeys(value);
|
||||
if (ownKeys.length !== stringKeys.length) {
|
||||
throw new TypeError("JSON objects must not contain symbols or non-enumerable properties");
|
||||
}
|
||||
for (const key of stringKeys) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
throw new TypeError("JSON objects must not contain accessor properties");
|
||||
}
|
||||
assertJsonValueInternal(descriptor.value, ancestors, depth + 1, budget);
|
||||
}
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertJsonValue(value: unknown): asserts value is JsonValue {
|
||||
assertJsonValueInternal(value, new WeakSet(), 0, { nodes: 0 });
|
||||
}
|
||||
|
||||
export interface JsonValuePropertyObservation {
|
||||
readonly depth: number;
|
||||
readonly parentKey: string | number | undefined;
|
||||
readonly key: string | number;
|
||||
readonly value: JsonValue;
|
||||
}
|
||||
|
||||
export type JsonValuePropertyObserver = (
|
||||
observation: JsonValuePropertyObservation,
|
||||
) => void;
|
||||
|
||||
function serializeValidatedJsonValue(
|
||||
value: JsonValue,
|
||||
observer: JsonValuePropertyObserver | undefined,
|
||||
depth: number,
|
||||
parentKey: string | number | undefined,
|
||||
): string {
|
||||
if (value === null || typeof value !== "object") {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized === undefined) throw new TypeError("Value is not serializable JSON");
|
||||
return serialized;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const serializedItems: string[] = [];
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
throw new TypeError("JSON arrays must contain data properties only");
|
||||
}
|
||||
const item = descriptor.value as JsonValue;
|
||||
observer?.({ depth, parentKey, key: index, value: item });
|
||||
serializedItems.push(serializeValidatedJsonValue(item, observer, depth + 1, index));
|
||||
}
|
||||
return `[${serializedItems.join(",")}]`;
|
||||
}
|
||||
const serializedEntries: string[] = [];
|
||||
for (const key of Object.keys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
throw new TypeError("JSON objects must contain data properties only");
|
||||
}
|
||||
const propertyValue = descriptor.value as JsonValue;
|
||||
observer?.({ depth, parentKey, key, value: propertyValue });
|
||||
serializedEntries.push(
|
||||
`${JSON.stringify(key)}:${serializeValidatedJsonValue(
|
||||
propertyValue,
|
||||
observer,
|
||||
depth + 1,
|
||||
key,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return `{${serializedEntries.join(",")}}`;
|
||||
}
|
||||
|
||||
export function serializeJsonValueWithPropertyObserver(
|
||||
value: unknown,
|
||||
observer: JsonValuePropertyObserver | undefined,
|
||||
): string {
|
||||
assertJsonValue(value);
|
||||
return serializeValidatedJsonValue(value, observer, 0, undefined);
|
||||
}
|
||||
|
||||
export function serializeJsonValue(value: unknown): string {
|
||||
return serializeJsonValueWithPropertyObserver(value, undefined);
|
||||
}
|
||||
215
packages/plugin-contract/src/stdioFraming.test.ts
Normal file
215
packages/plugin-contract/src/stdioFraming.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
COMPANION_STDIO_MAX_CONTENT_BYTES,
|
||||
ContentLengthFrameDecoder,
|
||||
encodeContentLengthFrame,
|
||||
} from "./stdioFraming.ts";
|
||||
|
||||
test("content-length framing round-trips fragmented and coalesced JSON messages", () => {
|
||||
const first = encodeContentLengthFrame({ jsonrpc: "2.0", id: 1, method: "plugin.initialize" });
|
||||
const second = encodeContentLengthFrame({ jsonrpc: "2.0", id: 1, result: { ok: true } });
|
||||
const joined = new Uint8Array(first.byteLength + second.byteLength);
|
||||
joined.set(first);
|
||||
joined.set(second, first.byteLength);
|
||||
|
||||
const decoder = new ContentLengthFrameDecoder();
|
||||
assert.deepEqual(decoder.push(joined.subarray(0, 7)), []);
|
||||
assert.deepEqual(decoder.push(joined.subarray(7, first.byteLength + 3)), [
|
||||
{ jsonrpc: "2.0", id: 1, method: "plugin.initialize" },
|
||||
]);
|
||||
assert.deepEqual(decoder.push(joined.subarray(first.byteLength + 3)), [
|
||||
{ jsonrpc: "2.0", id: 1, result: { ok: true } },
|
||||
]);
|
||||
assert.doesNotThrow(() => decoder.finish());
|
||||
|
||||
for (let split = 0; split <= first.byteLength; split += 1) {
|
||||
const splitDecoder = new ContentLengthFrameDecoder();
|
||||
const message = { jsonrpc: "2.0", id: 1, method: "plugin.initialize" };
|
||||
assert.deepEqual(
|
||||
splitDecoder.push(first.subarray(0, split)),
|
||||
split === first.byteLength ? [message] : [],
|
||||
);
|
||||
assert.deepEqual(
|
||||
splitDecoder.push(first.subarray(split)),
|
||||
split === first.byteLength ? [] : [message],
|
||||
);
|
||||
assert.doesNotThrow(() => splitDecoder.finish());
|
||||
}
|
||||
});
|
||||
|
||||
test("content-length framing rejects ambiguous headers and oversized payloads", () => {
|
||||
assert.throws(
|
||||
() => encodeContentLengthFrame(undefined as never),
|
||||
/Unsupported JSON value type/,
|
||||
);
|
||||
assert.throws(
|
||||
() => encodeContentLengthFrame({ value: Number.NaN } as never),
|
||||
/JSON numbers must be finite/,
|
||||
);
|
||||
assert.throws(
|
||||
() => encodeContentLengthFrame({
|
||||
streamId: "stream-1",
|
||||
sequence: 1,
|
||||
kind: "chunk",
|
||||
data: { encoding: "transfer", byteLength: 4 },
|
||||
}),
|
||||
/cannot be encoded over companion stdio/,
|
||||
);
|
||||
let accessorReads = 0;
|
||||
const accessorFrame = { streamId: "stream-1", sequence: 1 } as Record<string, unknown>;
|
||||
Object.defineProperty(accessorFrame, "kind", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
accessorReads += 1;
|
||||
return "chunk";
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() => encodeContentLengthFrame(accessorFrame as never),
|
||||
/must not contain accessor properties/,
|
||||
);
|
||||
assert.equal(accessorReads, 0, "framing must reject accessors without invoking them");
|
||||
|
||||
let proxyReads = 0;
|
||||
const proxyFrame = new Proxy({
|
||||
streamId: "stream-1",
|
||||
sequence: 1,
|
||||
kind: "chunk",
|
||||
data: { encoding: "transfer", byteLength: 4 },
|
||||
}, {
|
||||
get(target, property, receiver) {
|
||||
proxyReads += 1;
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() => encodeContentLengthFrame(proxyFrame),
|
||||
/cannot be encoded over companion stdio/,
|
||||
);
|
||||
assert.equal(proxyReads, 0, "framing must inspect descriptor values instead of reading proxy fields");
|
||||
|
||||
let inheritedReads = 0;
|
||||
const pollutedPrototype = {} as Record<string, unknown>;
|
||||
Object.defineProperty(pollutedPrototype, "kind", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
inheritedReads += 1;
|
||||
return "chunk";
|
||||
},
|
||||
});
|
||||
const pollutedFrame = Object.assign(Object.create(pollutedPrototype), {
|
||||
streamId: "stream-1",
|
||||
sequence: 1,
|
||||
data: { encoding: "transfer", byteLength: 4 },
|
||||
});
|
||||
assert.throws(
|
||||
() => encodeContentLengthFrame(pollutedFrame),
|
||||
/plain records/,
|
||||
);
|
||||
assert.equal(inheritedReads, 0, "framing must reject polluted prototypes without reading them");
|
||||
const duplicate = new ContentLengthFrameDecoder();
|
||||
assert.throws(
|
||||
() => duplicate.push("Content-Length: 2\r\ncontent-length: 2\r\n\r\n{}"),
|
||||
/Duplicate companion stdio header/,
|
||||
);
|
||||
|
||||
const whitespaceBeforeColon = new ContentLengthFrameDecoder();
|
||||
assert.throws(
|
||||
() => whitespaceBeforeColon.push("Content-Length : 2\r\n\r\n{}"),
|
||||
/Malformed companion stdio header/,
|
||||
);
|
||||
|
||||
const unsupported = new ContentLengthFrameDecoder();
|
||||
assert.throws(
|
||||
() => unsupported.push("Content-Length: 2\r\nX-Mode: unsafe\r\n\r\n{}"),
|
||||
/Unsupported companion stdio header/,
|
||||
);
|
||||
|
||||
const oversized = new ContentLengthFrameDecoder({ maxContentBytes: 4 });
|
||||
assert.throws(
|
||||
() => oversized.push("Content-Length: 5\r\n\r\n12345"),
|
||||
/exceeds 4 bytes/,
|
||||
);
|
||||
|
||||
const truncated = new ContentLengthFrameDecoder();
|
||||
assert.deepEqual(truncated.push("Content-Length: 5\r\n\r\n12"), []);
|
||||
assert.throws(() => truncated.finish(), /truncated frame/);
|
||||
assert.throws(
|
||||
() => new ContentLengthFrameDecoder({
|
||||
maxContentBytes: COMPANION_STDIO_MAX_CONTENT_BYTES + 1,
|
||||
}),
|
||||
/must be an integer between/,
|
||||
);
|
||||
|
||||
for (const payload of ["1e999", '{"value":1e999}']) {
|
||||
const nonFinite = new ContentLengthFrameDecoder();
|
||||
const payloadBytes = new TextEncoder().encode(payload).byteLength;
|
||||
assert.throws(
|
||||
() => nonFinite.push(`Content-Length: ${payloadBytes}\r\n\r\n${payload}`),
|
||||
/outside the JSON value contract: JSON numbers must be finite/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("content-length framing accepts a split delimiter at the header byte limit", () => {
|
||||
const maxHeaderBytes = 32;
|
||||
const header = `Content-Length:${" ".repeat(16)}2`;
|
||||
assert.equal(new TextEncoder().encode(header).byteLength, maxHeaderBytes);
|
||||
|
||||
const separator = "\r\n\r\n";
|
||||
for (let split = 0; split <= separator.length; split += 1) {
|
||||
const decoder = new ContentLengthFrameDecoder({ maxHeaderBytes });
|
||||
assert.deepEqual(decoder.push(`${header}${separator.slice(0, split)}`), []);
|
||||
assert.deepEqual(decoder.push(`${separator.slice(split)}{}`), [{}]);
|
||||
assert.doesNotThrow(() => decoder.finish());
|
||||
}
|
||||
|
||||
const oversized = new ContentLengthFrameDecoder({ maxHeaderBytes });
|
||||
assert.throws(
|
||||
() => oversized.push(`${header} \r\n\r\n{}`),
|
||||
/header exceeds 32 bytes/,
|
||||
);
|
||||
});
|
||||
|
||||
test("content-length framing stays linear under adversarial byte fragmentation", () => {
|
||||
const message = { value: "x".repeat(100_000) };
|
||||
const frame = encodeContentLengthFrame(message);
|
||||
const decoder = new ContentLengthFrameDecoder();
|
||||
const startedAt = performance.now();
|
||||
let decoded: unknown[] = [];
|
||||
for (let index = 0; index < frame.byteLength; index += 1) {
|
||||
const messages = decoder.push(frame.subarray(index, index + 1));
|
||||
if (messages.length > 0) decoded = messages;
|
||||
}
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
assert.deepEqual(decoded, [message]);
|
||||
assert.doesNotThrow(() => decoder.finish());
|
||||
assert.ok(
|
||||
elapsedMs < 3_000,
|
||||
`byte-fragmented frame decoding took ${Math.round(elapsedMs)}ms`,
|
||||
);
|
||||
});
|
||||
|
||||
test("content-length framing coalesces fragmented headers without losing the body", () => {
|
||||
const header = `Content-Length:${" ".repeat(2_000)}2\r\n\r\n`;
|
||||
const bytes = new TextEncoder().encode(`${header}{}`);
|
||||
const decoder = new ContentLengthFrameDecoder();
|
||||
let decoded: unknown[] = [];
|
||||
for (let index = 0; index < bytes.byteLength; index += 1) {
|
||||
const messages = decoder.push(bytes.subarray(index, index + 1));
|
||||
if (messages.length > 0) decoded = messages;
|
||||
}
|
||||
assert.deepEqual(decoded, [{}]);
|
||||
assert.doesNotThrow(() => decoder.finish());
|
||||
});
|
||||
|
||||
test("content-length framing snapshots Buffer input before returning", () => {
|
||||
const decoder = new ContentLengthFrameDecoder();
|
||||
const prefix = Buffer.from("Content-Length: 2\r\n\r\n{");
|
||||
assert.deepEqual(decoder.push(prefix), []);
|
||||
prefix.fill(0);
|
||||
assert.deepEqual(decoder.push("}"), [{}]);
|
||||
assert.doesNotThrow(() => decoder.finish());
|
||||
});
|
||||
272
packages/plugin-contract/src/stdioFraming.ts
Normal file
272
packages/plugin-contract/src/stdioFraming.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import type {
|
||||
JsonValue,
|
||||
RpcMessage,
|
||||
StreamFrame,
|
||||
} from "./generated/plugin-contract.js";
|
||||
import {
|
||||
assertJsonValue,
|
||||
serializeJsonValueWithPropertyObserver,
|
||||
} from "./jsonValue.js";
|
||||
|
||||
export const COMPANION_STDIO_MAX_HEADER_BYTES = 8 * 1024;
|
||||
export const COMPANION_STDIO_MAX_CONTENT_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
const HEADER_SEPARATOR = new Uint8Array([13, 10, 13, 10]);
|
||||
const ABSOLUTE_MAX_HEADER_BYTES = 64 * 1024;
|
||||
const BYTE_QUEUE_SLAB_BYTES = 64 * 1024;
|
||||
const encoder = new TextEncoder();
|
||||
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
interface ByteQueueChunk {
|
||||
readonly bytes: Uint8Array;
|
||||
length: number;
|
||||
}
|
||||
|
||||
class ByteQueue {
|
||||
readonly #chunks: ByteQueueChunk[] = [];
|
||||
#headIndex = 0;
|
||||
#headOffset = 0;
|
||||
#byteLength = 0;
|
||||
|
||||
get byteLength(): number {
|
||||
return this.#byteLength;
|
||||
}
|
||||
|
||||
push(chunk: Uint8Array): void {
|
||||
if (chunk.byteLength === 0) return;
|
||||
let inputOffset = 0;
|
||||
while (inputOffset < chunk.byteLength) {
|
||||
let tail = this.#chunks.at(-1);
|
||||
if (!tail || tail.length === tail.bytes.byteLength) {
|
||||
const remaining = chunk.byteLength - inputOffset;
|
||||
const capacity = remaining >= BYTE_QUEUE_SLAB_BYTES
|
||||
? remaining
|
||||
: BYTE_QUEUE_SLAB_BYTES;
|
||||
tail = { bytes: new Uint8Array(capacity), length: 0 };
|
||||
this.#chunks.push(tail);
|
||||
}
|
||||
const take = Math.min(
|
||||
tail.bytes.byteLength - tail.length,
|
||||
chunk.byteLength - inputOffset,
|
||||
);
|
||||
tail.bytes.set(chunk.subarray(inputOffset, inputOffset + take), tail.length);
|
||||
tail.length += take;
|
||||
inputOffset += take;
|
||||
this.#byteLength += take;
|
||||
}
|
||||
}
|
||||
|
||||
indexOf(needle: Uint8Array, limit: number): number {
|
||||
let matched = 0;
|
||||
let index = 0;
|
||||
for (let chunkIndex = this.#headIndex; chunkIndex < this.#chunks.length; chunkIndex += 1) {
|
||||
const chunk = this.#chunks[chunkIndex];
|
||||
const start = chunkIndex === this.#headIndex ? this.#headOffset : 0;
|
||||
for (let offset = start; offset < chunk.length; offset += 1) {
|
||||
if (index >= limit) return -1;
|
||||
const byte = chunk.bytes[offset];
|
||||
if (byte === needle[matched]) {
|
||||
matched += 1;
|
||||
if (matched === needle.byteLength) return index - needle.byteLength + 1;
|
||||
} else {
|
||||
matched = byte === needle[0] ? 1 : 0;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
consume(byteLength: number): Uint8Array {
|
||||
if (byteLength < 0 || byteLength > this.#byteLength) {
|
||||
throw new RangeError(`Cannot consume ${byteLength} bytes from ${this.#byteLength}`);
|
||||
}
|
||||
const output = new Uint8Array(byteLength);
|
||||
let outputOffset = 0;
|
||||
let remaining = byteLength;
|
||||
while (remaining > 0) {
|
||||
const head = this.#chunks[this.#headIndex];
|
||||
const available = head.length - this.#headOffset;
|
||||
const take = Math.min(available, remaining);
|
||||
output.set(
|
||||
head.bytes.subarray(this.#headOffset, this.#headOffset + take),
|
||||
outputOffset,
|
||||
);
|
||||
outputOffset += take;
|
||||
remaining -= take;
|
||||
this.#headOffset += take;
|
||||
this.#byteLength -= take;
|
||||
if (this.#headOffset === head.length) {
|
||||
this.#headIndex += 1;
|
||||
this.#headOffset = 0;
|
||||
}
|
||||
}
|
||||
if (this.#byteLength === 0) {
|
||||
this.#chunks.length = 0;
|
||||
this.#headIndex = 0;
|
||||
} else if (this.#headIndex >= 1_024 && this.#headIndex * 2 >= this.#chunks.length) {
|
||||
this.#chunks.splice(0, this.#headIndex);
|
||||
this.#headIndex = 0;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeAscii(bytes: Uint8Array): string {
|
||||
for (const byte of bytes) {
|
||||
if (byte > 0x7f) throw new Error("Companion stdio headers must contain ASCII only");
|
||||
}
|
||||
return utf8Decoder.decode(bytes);
|
||||
}
|
||||
|
||||
function parseContentLength(headerBytes: Uint8Array, maxContentBytes: number): number {
|
||||
const values = new Map<string, string>();
|
||||
for (const line of decodeAscii(headerBytes).split("\r\n")) {
|
||||
const match = /^([A-Za-z][A-Za-z0-9-]*):[ \t]*(.*)$/.exec(line);
|
||||
if (!match) throw new Error(`Malformed companion stdio header: ${line}`);
|
||||
const name = match[1].toLowerCase();
|
||||
const value = match[2].trim();
|
||||
if (values.has(name)) throw new Error(`Duplicate companion stdio header: ${name}`);
|
||||
if (name !== "content-length" && name !== "content-type") {
|
||||
throw new Error(`Unsupported companion stdio header: ${name}`);
|
||||
}
|
||||
values.set(name, value);
|
||||
}
|
||||
|
||||
const rawLength = values.get("content-length");
|
||||
if (!rawLength || !/^(0|[1-9]\d*)$/.test(rawLength)) {
|
||||
throw new Error("Companion stdio frame requires one decimal Content-Length header");
|
||||
}
|
||||
const contentLength = Number(rawLength);
|
||||
if (!Number.isSafeInteger(contentLength) || contentLength <= 0) {
|
||||
throw new Error("Companion stdio Content-Length must be a positive safe integer");
|
||||
}
|
||||
if (contentLength > maxContentBytes) {
|
||||
throw new Error(`Companion stdio frame exceeds ${maxContentBytes} bytes`);
|
||||
}
|
||||
|
||||
const contentType = values.get("content-type")?.toLowerCase();
|
||||
if (contentType !== undefined
|
||||
&& contentType !== "application/json"
|
||||
&& contentType !== "application/json; charset=utf-8") {
|
||||
throw new Error(`Unsupported companion stdio Content-Type: ${contentType}`);
|
||||
}
|
||||
return contentLength;
|
||||
}
|
||||
|
||||
export function encodeContentLengthFrame(
|
||||
value: JsonValue | RpcMessage | StreamFrame,
|
||||
): Uint8Array {
|
||||
let rootKind: JsonValue | undefined;
|
||||
let rootDataEncoding: JsonValue | undefined;
|
||||
const serialized = serializeJsonValueWithPropertyObserver(value, (observation) => {
|
||||
if (observation.depth === 0 && observation.key === "kind") {
|
||||
rootKind = observation.value;
|
||||
} else if (observation.depth === 1
|
||||
&& observation.parentKey === "data"
|
||||
&& observation.key === "encoding") {
|
||||
rootDataEncoding = observation.value;
|
||||
}
|
||||
});
|
||||
if (rootKind === "chunk" && rootDataEncoding === "transfer") {
|
||||
throw new Error("Transfer stream chunks cannot be encoded over companion stdio");
|
||||
}
|
||||
const content = encoder.encode(serialized);
|
||||
if (content.byteLength === 0 || content.byteLength > COMPANION_STDIO_MAX_CONTENT_BYTES) {
|
||||
throw new Error(
|
||||
`Companion stdio content must be between 1 and ${COMPANION_STDIO_MAX_CONTENT_BYTES} bytes`,
|
||||
);
|
||||
}
|
||||
const header = encoder.encode(
|
||||
`Content-Length: ${content.byteLength}\r\nContent-Type: application/json; charset=utf-8\r\n\r\n`,
|
||||
);
|
||||
const frame = new Uint8Array(header.byteLength + content.byteLength);
|
||||
frame.set(header, 0);
|
||||
frame.set(content, header.byteLength);
|
||||
return frame;
|
||||
}
|
||||
|
||||
export interface ContentLengthFrameDecoderOptions {
|
||||
readonly maxHeaderBytes?: number;
|
||||
readonly maxContentBytes?: number;
|
||||
}
|
||||
|
||||
export class ContentLengthFrameDecoder {
|
||||
readonly #queue = new ByteQueue();
|
||||
readonly #maxHeaderBytes: number;
|
||||
readonly #maxContentBytes: number;
|
||||
#expectedContentBytes: number | undefined;
|
||||
|
||||
constructor(options: ContentLengthFrameDecoderOptions = {}) {
|
||||
this.#maxHeaderBytes = options.maxHeaderBytes ?? COMPANION_STDIO_MAX_HEADER_BYTES;
|
||||
this.#maxContentBytes = options.maxContentBytes ?? COMPANION_STDIO_MAX_CONTENT_BYTES;
|
||||
if (!Number.isInteger(this.#maxHeaderBytes)
|
||||
|| this.#maxHeaderBytes < 32
|
||||
|| this.#maxHeaderBytes > ABSOLUTE_MAX_HEADER_BYTES) {
|
||||
throw new RangeError(
|
||||
`maxHeaderBytes must be an integer between 32 and ${ABSOLUTE_MAX_HEADER_BYTES}`,
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(this.#maxContentBytes)
|
||||
|| this.#maxContentBytes < 1
|
||||
|| this.#maxContentBytes > COMPANION_STDIO_MAX_CONTENT_BYTES) {
|
||||
throw new RangeError(
|
||||
`maxContentBytes must be an integer between 1 and ${COMPANION_STDIO_MAX_CONTENT_BYTES}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
push(chunk: Uint8Array | string): JsonValue[] {
|
||||
this.#queue.push(typeof chunk === "string" ? encoder.encode(chunk) : chunk);
|
||||
const messages: JsonValue[] = [];
|
||||
while (true) {
|
||||
if (this.#expectedContentBytes === undefined) {
|
||||
const separatorIndex = this.#queue.indexOf(
|
||||
HEADER_SEPARATOR,
|
||||
this.#maxHeaderBytes + HEADER_SEPARATOR.byteLength,
|
||||
);
|
||||
if (separatorIndex === -1) {
|
||||
const maximumIncompleteHeaderBytes = this.#maxHeaderBytes
|
||||
+ HEADER_SEPARATOR.byteLength
|
||||
- 1;
|
||||
if (this.#queue.byteLength > maximumIncompleteHeaderBytes) {
|
||||
throw new Error(`Companion stdio header exceeds ${this.#maxHeaderBytes} bytes`);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
if (separatorIndex > this.#maxHeaderBytes) {
|
||||
throw new Error(`Companion stdio header exceeds ${this.#maxHeaderBytes} bytes`);
|
||||
}
|
||||
const header = this.#queue.consume(separatorIndex + HEADER_SEPARATOR.byteLength)
|
||||
.subarray(0, separatorIndex);
|
||||
this.#expectedContentBytes = parseContentLength(header, this.#maxContentBytes);
|
||||
}
|
||||
|
||||
if (this.#queue.byteLength < this.#expectedContentBytes) return messages;
|
||||
const content = this.#queue.consume(this.#expectedContentBytes);
|
||||
this.#expectedContentBytes = undefined;
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(utf8Decoder.decode(content));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Companion stdio payload is not valid UTF-8 JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertJsonValue(value);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Companion stdio payload is outside the JSON value contract: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
messages.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
if (this.#expectedContentBytes !== undefined || this.#queue.byteLength > 0) {
|
||||
throw new Error("Companion stdio stream ended with a truncated frame");
|
||||
}
|
||||
}
|
||||
}
|
||||
417
packages/plugin-contract/src/streamTransport.test.ts
Normal file
417
packages/plugin-contract/src/streamTransport.test.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
import {
|
||||
PLUGIN_JSON_MAX_DEPTH,
|
||||
PLUGIN_JSON_MAX_NODES,
|
||||
assertJsonValue,
|
||||
serializeJsonValue,
|
||||
} from "./jsonValue.ts";
|
||||
import {
|
||||
PLUGIN_STREAM_MAX_CHUNK_BYTES,
|
||||
PLUGIN_STREAM_MAX_CREDIT_BYTES,
|
||||
PLUGIN_STREAM_MAX_ID_LENGTH,
|
||||
PLUGIN_STREAM_MAX_WINDOW_BYTES,
|
||||
PLUGIN_STREAM_MIN_WINDOW_BYTES,
|
||||
assertStreamChunkData,
|
||||
assertStreamFrame,
|
||||
createBase64StreamChunk,
|
||||
createJsonStreamChunk,
|
||||
createMessagePortStreamEnvelope,
|
||||
materializeStreamChunk,
|
||||
} from "./streamTransport.ts";
|
||||
import { PLUGIN_WIRE_MAX_SAFE_INTEGER } from "./generated/plugin-contract-limits.ts";
|
||||
|
||||
test("validated JSON serialization matches standard JSON bytes for plain values", () => {
|
||||
const values = [
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
0,
|
||||
-0,
|
||||
1.25,
|
||||
1e30,
|
||||
"quotes \" slashes \\ controls \n unicode 你好",
|
||||
[],
|
||||
[null, true, 3, "value", { nested: [1, 2, 3] }],
|
||||
{ first: 1, second: "two", third: false },
|
||||
{ 10: "ten", 2: "two", tail: "last" },
|
||||
];
|
||||
for (const value of values) {
|
||||
assert.equal(serializeJsonValue(value), JSON.stringify(value));
|
||||
}
|
||||
});
|
||||
|
||||
test("JSON validation rejects excessive structural depth and node counts", () => {
|
||||
let deepValue: unknown = null;
|
||||
for (let depth = 0; depth <= PLUGIN_JSON_MAX_DEPTH; depth += 1) {
|
||||
deepValue = [deepValue];
|
||||
}
|
||||
assert.throws(
|
||||
() => assertJsonValue(deepValue),
|
||||
new RegExp(`must not exceed ${PLUGIN_JSON_MAX_DEPTH} levels`),
|
||||
);
|
||||
|
||||
const wideValue = Array.from({ length: PLUGIN_JSON_MAX_NODES }, () => null);
|
||||
assert.throws(
|
||||
() => assertJsonValue(wideValue),
|
||||
new RegExp(`must not contain more than ${PLUGIN_JSON_MAX_NODES} nodes`),
|
||||
);
|
||||
});
|
||||
|
||||
test("JSON stream chunks use verified UTF-8 byte accounting", () => {
|
||||
const chunk = createJsonStreamChunk({ text: "你好" });
|
||||
assert.equal(chunk.encoding, "json");
|
||||
assert.equal(chunk.byteLength, new TextEncoder().encode('{"text":"你好"}').byteLength);
|
||||
assert.deepEqual(materializeStreamChunk(chunk), {
|
||||
encoding: "json",
|
||||
value: { text: "你好" },
|
||||
});
|
||||
assert.throws(
|
||||
() => materializeStreamChunk({ ...chunk, byteLength: chunk.byteLength + 1 }),
|
||||
/JSON byteLength mismatch/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createJsonStreamChunk({ value: Number.NaN } as never),
|
||||
/JSON numbers must be finite/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createJsonStreamChunk({ value: undefined } as never),
|
||||
/Unsupported JSON value type/,
|
||||
);
|
||||
const sparse = new Array(2) as never;
|
||||
assert.throws(() => createJsonStreamChunk(sparse), /JSON arrays must be dense/);
|
||||
const accessor = {} as Record<string, unknown>;
|
||||
Object.defineProperty(accessor, "value", { enumerable: true, get: () => "unsafe" });
|
||||
assert.throws(
|
||||
() => createJsonStreamChunk(accessor as never),
|
||||
/must not contain accessor properties/,
|
||||
);
|
||||
class CustomJsonValue {
|
||||
readonly value = "validated";
|
||||
|
||||
toJSON() {
|
||||
return { value: "different" };
|
||||
}
|
||||
}
|
||||
assert.throws(
|
||||
() => createJsonStreamChunk(new CustomJsonValue() as never),
|
||||
/plain records/,
|
||||
);
|
||||
|
||||
const arrayWithInheritedToJson = ["validated"];
|
||||
Object.setPrototypeOf(arrayWithInheritedToJson, {
|
||||
toJSON: () => ["different"],
|
||||
});
|
||||
const inheritedToJsonChunk = createJsonStreamChunk(arrayWithInheritedToJson);
|
||||
assert.equal(inheritedToJsonChunk.byteLength, new TextEncoder().encode('["validated"]').byteLength);
|
||||
|
||||
const nullPrototypeValue = Object.assign(Object.create(null), { value: "validated" });
|
||||
assert.deepEqual(createJsonStreamChunk(nullPrototypeValue), {
|
||||
encoding: "json",
|
||||
value: nullPrototypeValue,
|
||||
byteLength: new TextEncoder().encode('{"value":"validated"}').byteLength,
|
||||
});
|
||||
});
|
||||
|
||||
test("base64 stream chunks round-trip bytes and reject length or encoding ambiguity", () => {
|
||||
const bytes = new Uint8Array([0, 1, 2, 127, 128, 253, 254, 255]);
|
||||
const chunk = createBase64StreamChunk(bytes);
|
||||
const materialized = materializeStreamChunk(chunk);
|
||||
assert.equal(materialized.encoding, "binary");
|
||||
assert.deepEqual(materialized.bytes, bytes);
|
||||
|
||||
assert.throws(
|
||||
() => materializeStreamChunk({ ...chunk, byteLength: bytes.byteLength + 1 }),
|
||||
/byteLength mismatch/,
|
||||
);
|
||||
assert.throws(
|
||||
() => materializeStreamChunk({ encoding: "base64", value: "not-base64", byteLength: 1 }),
|
||||
/canonical RFC 4648 base64/,
|
||||
);
|
||||
assert.throws(
|
||||
() => materializeStreamChunk({ encoding: "base64", value: "AB==", byteLength: 1 }),
|
||||
/canonical RFC 4648 base64/,
|
||||
);
|
||||
assert.throws(
|
||||
() => materializeStreamChunk({
|
||||
encoding: "base64",
|
||||
value: "",
|
||||
byteLength: PLUGIN_STREAM_MAX_CHUNK_BYTES + 1,
|
||||
}),
|
||||
/byteLength must be an integer between/,
|
||||
);
|
||||
|
||||
for (let length = 0; length <= 257; length += 1) {
|
||||
const sample = Uint8Array.from(
|
||||
{ length },
|
||||
(_, index) => (length * 17 + index * 31) & 0xff,
|
||||
);
|
||||
const roundTrip = materializeStreamChunk(createBase64StreamChunk(sample));
|
||||
assert.equal(roundTrip.encoding, "binary");
|
||||
assert.deepEqual(roundTrip.bytes, sample, `base64 length ${length}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("stream chunk assertions validate inline bytes before accepting frames", () => {
|
||||
const jsonChunk = createJsonStreamChunk({ text: "你好" });
|
||||
const base64Chunk = createBase64StreamChunk(new Uint8Array([0, 1, 2, 255]));
|
||||
assert.doesNotThrow(() => assertStreamChunkData(jsonChunk));
|
||||
assert.doesNotThrow(() => assertStreamChunkData(base64Chunk));
|
||||
assert.doesNotThrow(() => assertStreamChunkData({ encoding: "transfer", byteLength: 4 }));
|
||||
|
||||
const invalidInlineChunks: readonly [unknown, RegExp][] = [
|
||||
[{ ...jsonChunk, byteLength: jsonChunk.byteLength + 1 }, /JSON byteLength mismatch/],
|
||||
[{ ...base64Chunk, byteLength: base64Chunk.byteLength + 1 }, /base64 byteLength mismatch/],
|
||||
[
|
||||
{ encoding: "base64", value: "not-base64", byteLength: 1 },
|
||||
/canonical RFC 4648 base64/,
|
||||
],
|
||||
[{ encoding: "base64", value: "AB==", byteLength: 1 }, /canonical RFC 4648 base64/],
|
||||
];
|
||||
for (const [data, expectedError] of invalidInlineChunks) {
|
||||
assert.throws(() => assertStreamChunkData(data), expectedError);
|
||||
assert.throws(
|
||||
() => assertStreamFrame({ streamId: "stream-1", sequence: 1, kind: "chunk", data }),
|
||||
expectedError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("MessagePort stream envelopes carry and validate the transferred ArrayBuffer", () => {
|
||||
const transfer = new Uint8Array([1, 2, 3, 4]).buffer;
|
||||
const frame = {
|
||||
streamId: "stream-1",
|
||||
sequence: 1,
|
||||
kind: "chunk" as const,
|
||||
data: { encoding: "transfer" as const, byteLength: 4 },
|
||||
};
|
||||
const envelope = createMessagePortStreamEnvelope(frame, transfer);
|
||||
assert.equal(envelope.transfer, transfer);
|
||||
const materialized = materializeStreamChunk(frame.data, envelope.transfer);
|
||||
assert.equal(materialized.encoding, "binary");
|
||||
assert.deepEqual(materialized.bytes, new Uint8Array([1, 2, 3, 4]));
|
||||
|
||||
const crossRealmTransfer = runInNewContext("new ArrayBuffer(4)") as ArrayBuffer;
|
||||
assert.equal(crossRealmTransfer instanceof ArrayBuffer, false);
|
||||
const crossRealmMaterialized = materializeStreamChunk(frame.data, crossRealmTransfer);
|
||||
assert.equal(crossRealmMaterialized.encoding, "binary");
|
||||
assert.equal(crossRealmMaterialized.bytes.byteLength, 4);
|
||||
|
||||
assert.throws(
|
||||
() => createMessagePortStreamEnvelope(frame),
|
||||
/require an ArrayBuffer/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createMessagePortStreamEnvelope(frame, { byteLength: 4 } as never),
|
||||
/require a real, attached ArrayBuffer/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createMessagePortStreamEnvelope(
|
||||
frame,
|
||||
{
|
||||
byteLength: 4,
|
||||
[Symbol.toStringTag]: "ArrayBuffer",
|
||||
} as never,
|
||||
),
|
||||
/require a real, attached ArrayBuffer/,
|
||||
);
|
||||
const detached = new ArrayBuffer(4);
|
||||
structuredClone(detached, { transfer: [detached] });
|
||||
assert.throws(
|
||||
() => createMessagePortStreamEnvelope(
|
||||
{ ...frame, data: { ...frame.data, byteLength: 0 } },
|
||||
detached,
|
||||
),
|
||||
/require a real, attached ArrayBuffer/,
|
||||
);
|
||||
assert.throws(
|
||||
() => materializeStreamChunk(
|
||||
{ encoding: "bogus", byteLength: 4 } as never,
|
||||
transfer,
|
||||
),
|
||||
/Unsupported stream chunk encoding/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createMessagePortStreamEnvelope(
|
||||
{ streamId: "stream-1", sequence: 0, kind: "open", windowBytes: 65_536 },
|
||||
transfer,
|
||||
),
|
||||
/Only transfer-encoded chunk frames/,
|
||||
);
|
||||
assert.deepEqual(createMessagePortStreamEnvelope({
|
||||
streamId: "stream-1",
|
||||
sequence: PLUGIN_WIRE_MAX_SAFE_INTEGER,
|
||||
kind: "windowUpdate",
|
||||
creditBytes: 4096,
|
||||
}), {
|
||||
frame: {
|
||||
streamId: "stream-1",
|
||||
sequence: PLUGIN_WIRE_MAX_SAFE_INTEGER,
|
||||
kind: "windowUpdate",
|
||||
creditBytes: 4096,
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() => createMessagePortStreamEnvelope({
|
||||
streamId: "stream-1",
|
||||
sequence: PLUGIN_WIRE_MAX_SAFE_INTEGER + 1,
|
||||
kind: "windowUpdate",
|
||||
creditBytes: 4096,
|
||||
}),
|
||||
/sequence must be a safe integer/,
|
||||
);
|
||||
assert.doesNotThrow(() => createMessagePortStreamEnvelope({
|
||||
streamId: "stream-1",
|
||||
sequence: 0,
|
||||
kind: "open",
|
||||
windowBytes: PLUGIN_STREAM_MIN_WINDOW_BYTES,
|
||||
}));
|
||||
assert.doesNotThrow(() => createMessagePortStreamEnvelope({
|
||||
streamId: "stream-1",
|
||||
sequence: 0,
|
||||
kind: "open",
|
||||
windowBytes: PLUGIN_STREAM_MAX_WINDOW_BYTES,
|
||||
}));
|
||||
for (const windowBytes of [
|
||||
0,
|
||||
PLUGIN_STREAM_MIN_WINDOW_BYTES - 1,
|
||||
PLUGIN_STREAM_MAX_WINDOW_BYTES + 1,
|
||||
Number.POSITIVE_INFINITY,
|
||||
]) {
|
||||
assert.throws(
|
||||
() => createMessagePortStreamEnvelope({
|
||||
streamId: "stream-1",
|
||||
sequence: 0,
|
||||
kind: "open",
|
||||
windowBytes,
|
||||
}),
|
||||
/windowBytes must be an integer between|JSON numbers must be finite/,
|
||||
);
|
||||
}
|
||||
assert.doesNotThrow(() => createMessagePortStreamEnvelope({
|
||||
streamId: "stream-1",
|
||||
sequence: 0,
|
||||
kind: "windowUpdate",
|
||||
creditBytes: PLUGIN_STREAM_MAX_CREDIT_BYTES,
|
||||
}));
|
||||
for (const creditBytes of [
|
||||
0,
|
||||
PLUGIN_STREAM_MAX_CREDIT_BYTES + 1,
|
||||
Number.POSITIVE_INFINITY,
|
||||
]) {
|
||||
assert.throws(
|
||||
() => createMessagePortStreamEnvelope({
|
||||
streamId: "stream-1",
|
||||
sequence: 0,
|
||||
kind: "windowUpdate",
|
||||
creditBytes,
|
||||
}),
|
||||
/creditBytes must be an integer between|JSON numbers must be finite/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("MessagePort stream envelopes reject frames outside the complete wire schema", () => {
|
||||
const validError = {
|
||||
streamId: "stream-1",
|
||||
sequence: 1,
|
||||
kind: "error",
|
||||
error: { code: -32001, message: "cancelled", data: { retryable: false } },
|
||||
};
|
||||
assert.doesNotThrow(() => assertStreamFrame(validError));
|
||||
assert.doesNotThrow(() => createMessagePortStreamEnvelope(validError));
|
||||
|
||||
const invalidFrames: readonly [unknown, RegExp][] = [
|
||||
[null, /plain JSON object/],
|
||||
[[], /plain JSON object/],
|
||||
[{ streamId: "", sequence: 1, kind: "end" }, /between 1 and/],
|
||||
[
|
||||
{ streamId: "x".repeat(PLUGIN_STREAM_MAX_ID_LENGTH + 1), sequence: 1, kind: "end" },
|
||||
/between 1 and/,
|
||||
],
|
||||
[{ streamId: "stream-1", sequence: 1, kind: "bogus" }, /Unsupported stream frame kind/],
|
||||
[{ streamId: "stream-1", sequence: 0, kind: "open" }, /missing or unsupported/],
|
||||
[
|
||||
{ streamId: "stream-1", sequence: 0, kind: "open", windowBytes: 4096, extra: true },
|
||||
/missing or unsupported/,
|
||||
],
|
||||
[{ streamId: "stream-1", sequence: 1, kind: "chunk", data: null }, /plain JSON object/],
|
||||
[
|
||||
{
|
||||
streamId: "stream-1",
|
||||
sequence: 1,
|
||||
kind: "chunk",
|
||||
data: { encoding: "bogus", byteLength: 0 },
|
||||
},
|
||||
/Unsupported stream chunk encoding/,
|
||||
],
|
||||
[
|
||||
{
|
||||
streamId: "stream-1",
|
||||
sequence: 1,
|
||||
kind: "chunk",
|
||||
data: { encoding: "transfer", byteLength: 0, value: "extra" },
|
||||
},
|
||||
/missing or unsupported/,
|
||||
],
|
||||
[{ streamId: "stream-1", sequence: 1, kind: "end", data: null }, /missing or unsupported/],
|
||||
[
|
||||
{
|
||||
streamId: "stream-1",
|
||||
sequence: 1,
|
||||
kind: "error",
|
||||
error: { code: -1, message: "bad" },
|
||||
},
|
||||
/supported RPC error code/,
|
||||
],
|
||||
[
|
||||
{
|
||||
streamId: "stream-1",
|
||||
sequence: 1,
|
||||
kind: "error",
|
||||
error: { code: -32001, message: "", data: null },
|
||||
},
|
||||
/between 1 and/,
|
||||
],
|
||||
[
|
||||
{
|
||||
streamId: "stream-1",
|
||||
sequence: 1,
|
||||
kind: "error",
|
||||
error: { code: -32001, message: "bad", extra: true },
|
||||
},
|
||||
/missing or unsupported/,
|
||||
],
|
||||
[{ streamId: "stream-1", sequence: 0, kind: "cancel" }, /sequence must be a safe integer/],
|
||||
[
|
||||
{
|
||||
streamId: "stream-1",
|
||||
sequence: 0,
|
||||
kind: "windowUpdate",
|
||||
creditBytes: 1,
|
||||
extra: true,
|
||||
},
|
||||
/missing or unsupported/,
|
||||
],
|
||||
];
|
||||
for (const [frame, expectedError] of invalidFrames) {
|
||||
assert.throws(() => createMessagePortStreamEnvelope(frame), expectedError);
|
||||
}
|
||||
|
||||
let getterRead = false;
|
||||
const accessorFrame = { streamId: "stream-1", sequence: 1 } as Record<string, unknown>;
|
||||
Object.defineProperty(accessorFrame, "kind", {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
getterRead = true;
|
||||
return "end";
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() => createMessagePortStreamEnvelope(accessorFrame),
|
||||
/accessor properties/,
|
||||
);
|
||||
assert.equal(getterRead, false);
|
||||
});
|
||||
436
packages/plugin-contract/src/streamTransport.ts
Normal file
436
packages/plugin-contract/src/streamTransport.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import type {
|
||||
JsonValue,
|
||||
RpcErrorObject,
|
||||
StreamChunkData,
|
||||
StreamFrame,
|
||||
} from "./generated/plugin-contract.js";
|
||||
import {
|
||||
PLUGIN_RPC_ERROR_CODES,
|
||||
PLUGIN_STREAM_MAX_CHUNK_BYTES,
|
||||
PLUGIN_STREAM_MAX_CREDIT_BYTES,
|
||||
PLUGIN_STREAM_MAX_ID_LENGTH,
|
||||
PLUGIN_STREAM_MAX_WINDOW_BYTES,
|
||||
PLUGIN_STREAM_MIN_WINDOW_BYTES,
|
||||
PLUGIN_WIRE_MAX_SAFE_INTEGER,
|
||||
} from "./generated/plugin-contract-limits.js";
|
||||
import { assertJsonValue, serializeJsonValue } from "./jsonValue.js";
|
||||
|
||||
export {
|
||||
PLUGIN_STREAM_MAX_CHUNK_BYTES,
|
||||
PLUGIN_STREAM_MAX_CREDIT_BYTES,
|
||||
PLUGIN_STREAM_MAX_FRAME_JSON_BYTES,
|
||||
PLUGIN_STREAM_MAX_ID_LENGTH,
|
||||
PLUGIN_STREAM_MAX_WINDOW_BYTES,
|
||||
PLUGIN_STREAM_MIN_WINDOW_BYTES,
|
||||
} from "./generated/plugin-contract-limits.js";
|
||||
|
||||
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const BASE64_VALUE = new Map(
|
||||
[...BASE64_ALPHABET].map((character, index) => [character, index] as const),
|
||||
);
|
||||
|
||||
export interface MessagePortStreamEnvelope {
|
||||
readonly frame: StreamFrame;
|
||||
readonly transfer?: ArrayBuffer;
|
||||
}
|
||||
|
||||
const PLUGIN_STREAM_MAX_BASE64_CHARACTERS = 4 * Math.ceil(PLUGIN_STREAM_MAX_CHUNK_BYTES / 3);
|
||||
const RPC_ERROR_CODES = new Set<number>(PLUGIN_RPC_ERROR_CODES);
|
||||
|
||||
export type MaterializedStreamChunk =
|
||||
| { readonly encoding: "json"; readonly value: JsonValue }
|
||||
| { readonly encoding: "binary"; readonly bytes: Uint8Array };
|
||||
|
||||
const jsonEncoder = new TextEncoder();
|
||||
const arrayBufferByteLength = Object.getOwnPropertyDescriptor(
|
||||
ArrayBuffer.prototype,
|
||||
"byteLength",
|
||||
)?.get;
|
||||
|
||||
function materializeArrayBuffer(value: unknown): Uint8Array {
|
||||
if (!arrayBufferByteLength) {
|
||||
throw new TypeError("ArrayBuffer byteLength getter is unavailable");
|
||||
}
|
||||
try {
|
||||
arrayBufferByteLength.call(value);
|
||||
return new Uint8Array(value as ArrayBuffer);
|
||||
} catch {
|
||||
throw new TypeError("Transfer stream chunks require a real, attached ArrayBuffer");
|
||||
}
|
||||
}
|
||||
|
||||
function serializedJsonByteLength(value: JsonValue): number {
|
||||
const serialized = serializeJsonValue(value);
|
||||
return jsonEncoder.encode(serialized).byteLength;
|
||||
}
|
||||
|
||||
function assertChunkByteLength(byteLength: number): void {
|
||||
if (!Number.isInteger(byteLength)
|
||||
|| byteLength < 0
|
||||
|| byteLength > PLUGIN_STREAM_MAX_CHUNK_BYTES) {
|
||||
throw new RangeError(
|
||||
`Stream chunk byteLength must be an integer between 0 and ${PLUGIN_STREAM_MAX_CHUNK_BYTES}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, JsonValue>;
|
||||
|
||||
function readJsonRecord(value: unknown, label: string): JsonRecord {
|
||||
assertJsonValue(value);
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${label} must be a plain JSON object`);
|
||||
}
|
||||
const record: JsonRecord = Object.create(null) as JsonRecord;
|
||||
for (const key of Object.keys(value)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (!descriptor || !("value" in descriptor)) {
|
||||
throw new TypeError(`${label} must contain data properties only`);
|
||||
}
|
||||
record[key] = descriptor.value as JsonValue;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function assertExactKeys(
|
||||
record: JsonRecord,
|
||||
expectedKeys: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actualKeys = Object.keys(record);
|
||||
if (actualKeys.length !== expectedKeys.length
|
||||
|| expectedKeys.some((key) => !Object.hasOwn(record, key))) {
|
||||
throw new TypeError(`${label} has missing or unsupported properties`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertBoundedString(
|
||||
value: JsonValue | undefined,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string,
|
||||
): asserts value is string {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError(`${label} must be a string`);
|
||||
}
|
||||
const length = [...value].length;
|
||||
if (length < minimum || length > maximum) {
|
||||
throw new RangeError(`${label} must contain between ${minimum} and ${maximum} characters`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseRpcErrorObject(value: unknown): RpcErrorObject {
|
||||
const error = readJsonRecord(value, "Stream error");
|
||||
const expectedKeys = Object.hasOwn(error, "data")
|
||||
? ["code", "message", "data"]
|
||||
: ["code", "message"];
|
||||
assertExactKeys(error, expectedKeys, "Stream error");
|
||||
if (typeof error.code !== "number"
|
||||
|| !Number.isInteger(error.code)
|
||||
|| !RPC_ERROR_CODES.has(error.code)) {
|
||||
throw new RangeError("Stream error code is not a supported RPC error code");
|
||||
}
|
||||
assertBoundedString(error.message, 1, 2048, "Stream error message");
|
||||
return Object.hasOwn(error, "data")
|
||||
? {
|
||||
code: error.code as RpcErrorObject["code"],
|
||||
message: error.message,
|
||||
data: error.data ?? null,
|
||||
}
|
||||
: { code: error.code as RpcErrorObject["code"], message: error.message };
|
||||
}
|
||||
|
||||
function parseStreamChunkDataShape(value: unknown): StreamChunkData {
|
||||
const data = readJsonRecord(value, "Stream chunk data");
|
||||
if (data.encoding === "json") {
|
||||
assertExactKeys(data, ["encoding", "value", "byteLength"], "JSON stream chunk data");
|
||||
if (typeof data.byteLength !== "number") {
|
||||
throw new TypeError("Stream chunk byteLength must be a number");
|
||||
}
|
||||
assertChunkByteLength(data.byteLength);
|
||||
return { encoding: "json", value: data.value ?? null, byteLength: data.byteLength };
|
||||
} else if (data.encoding === "base64") {
|
||||
assertExactKeys(data, ["encoding", "value", "byteLength"], "Base64 stream chunk data");
|
||||
if (typeof data.value !== "string") {
|
||||
throw new TypeError("Base64 stream chunk value must be a string");
|
||||
}
|
||||
if (typeof data.byteLength !== "number") {
|
||||
throw new TypeError("Stream chunk byteLength must be a number");
|
||||
}
|
||||
assertChunkByteLength(data.byteLength);
|
||||
return { encoding: "base64", value: data.value, byteLength: data.byteLength };
|
||||
} else if (data.encoding === "transfer") {
|
||||
assertExactKeys(data, ["encoding", "byteLength"], "Transfer stream chunk data");
|
||||
if (typeof data.byteLength !== "number") {
|
||||
throw new TypeError("Stream chunk byteLength must be a number");
|
||||
}
|
||||
assertChunkByteLength(data.byteLength);
|
||||
return { encoding: "transfer", byteLength: data.byteLength };
|
||||
} else {
|
||||
throw new TypeError("Unsupported stream chunk encoding");
|
||||
}
|
||||
}
|
||||
|
||||
function assertInlineStreamChunkBytes(data: StreamChunkData): void {
|
||||
if (data.encoding === "json") {
|
||||
const byteLength = serializedJsonByteLength(data.value);
|
||||
if (byteLength !== data.byteLength) {
|
||||
throw new Error(
|
||||
`Stream JSON byteLength mismatch: declared ${data.byteLength}, encoded ${byteLength}`,
|
||||
);
|
||||
}
|
||||
} else if (data.encoding === "base64") {
|
||||
decodeValidatedBase64(data.value, data.byteLength);
|
||||
}
|
||||
}
|
||||
|
||||
function parseStreamChunkData(value: unknown): StreamChunkData {
|
||||
const data = parseStreamChunkDataShape(value);
|
||||
assertInlineStreamChunkBytes(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function assertStreamChunkData(value: unknown): asserts value is StreamChunkData {
|
||||
parseStreamChunkData(value);
|
||||
}
|
||||
|
||||
function assertStreamSequence(kind: StreamFrame["kind"], sequence: number): void {
|
||||
const minimum = kind === "open" || kind === "windowUpdate" ? 0 : 1;
|
||||
if (!Number.isSafeInteger(sequence)
|
||||
|| sequence < minimum
|
||||
|| sequence > PLUGIN_WIRE_MAX_SAFE_INTEGER
|
||||
|| (kind === "open" && sequence !== 0)) {
|
||||
const expected = kind === "open"
|
||||
? "exactly 0"
|
||||
: `a safe integer between ${minimum} and ${PLUGIN_WIRE_MAX_SAFE_INTEGER}`;
|
||||
throw new RangeError(`Stream ${kind} sequence must be ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStreamWindowBytes(windowBytes: number): void {
|
||||
if (!Number.isInteger(windowBytes)
|
||||
|| windowBytes < PLUGIN_STREAM_MIN_WINDOW_BYTES
|
||||
|| windowBytes > PLUGIN_STREAM_MAX_WINDOW_BYTES) {
|
||||
throw new RangeError(
|
||||
`Stream open windowBytes must be an integer between ${PLUGIN_STREAM_MIN_WINDOW_BYTES} and ${PLUGIN_STREAM_MAX_WINDOW_BYTES}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertStreamCreditBytes(creditBytes: number): void {
|
||||
if (!Number.isInteger(creditBytes)
|
||||
|| creditBytes < 1
|
||||
|| creditBytes > PLUGIN_STREAM_MAX_CREDIT_BYTES) {
|
||||
throw new RangeError(
|
||||
`Stream windowUpdate creditBytes must be an integer between 1 and ${PLUGIN_STREAM_MAX_CREDIT_BYTES}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseStreamFrame(value: unknown): StreamFrame {
|
||||
const frame = readJsonRecord(value, "Stream frame");
|
||||
assertBoundedString(frame.streamId, 1, PLUGIN_STREAM_MAX_ID_LENGTH, "Stream frame streamId");
|
||||
if (typeof frame.kind !== "string") {
|
||||
throw new TypeError("Stream frame kind must be a string");
|
||||
}
|
||||
if (typeof frame.sequence !== "number") {
|
||||
throw new TypeError("Stream frame sequence must be a number");
|
||||
}
|
||||
switch (frame.kind) {
|
||||
case "open": {
|
||||
assertExactKeys(frame, ["streamId", "sequence", "kind", "windowBytes"], "Open stream frame");
|
||||
if (typeof frame.windowBytes !== "number") {
|
||||
throw new TypeError("Stream open windowBytes must be a number");
|
||||
}
|
||||
assertStreamSequence(frame.kind, frame.sequence);
|
||||
assertStreamWindowBytes(frame.windowBytes);
|
||||
return {
|
||||
streamId: frame.streamId,
|
||||
sequence: 0,
|
||||
kind: "open",
|
||||
windowBytes: frame.windowBytes,
|
||||
};
|
||||
}
|
||||
case "chunk": {
|
||||
assertExactKeys(frame, ["streamId", "sequence", "kind", "data"], "Chunk stream frame");
|
||||
assertStreamSequence(frame.kind, frame.sequence);
|
||||
return {
|
||||
streamId: frame.streamId,
|
||||
sequence: frame.sequence,
|
||||
kind: "chunk",
|
||||
data: parseStreamChunkData(frame.data),
|
||||
};
|
||||
}
|
||||
case "end":
|
||||
case "cancel": {
|
||||
assertExactKeys(frame, ["streamId", "sequence", "kind"], `${frame.kind} stream frame`);
|
||||
assertStreamSequence(frame.kind, frame.sequence);
|
||||
return { streamId: frame.streamId, sequence: frame.sequence, kind: frame.kind };
|
||||
}
|
||||
case "error": {
|
||||
assertExactKeys(frame, ["streamId", "sequence", "kind", "error"], "Error stream frame");
|
||||
assertStreamSequence(frame.kind, frame.sequence);
|
||||
return {
|
||||
streamId: frame.streamId,
|
||||
sequence: frame.sequence,
|
||||
kind: "error",
|
||||
error: parseRpcErrorObject(frame.error),
|
||||
};
|
||||
}
|
||||
case "windowUpdate": {
|
||||
assertExactKeys(
|
||||
frame,
|
||||
["streamId", "sequence", "kind", "creditBytes"],
|
||||
"Window-update stream frame",
|
||||
);
|
||||
if (typeof frame.creditBytes !== "number") {
|
||||
throw new TypeError("Stream windowUpdate creditBytes must be a number");
|
||||
}
|
||||
assertStreamSequence(frame.kind, frame.sequence);
|
||||
assertStreamCreditBytes(frame.creditBytes);
|
||||
return {
|
||||
streamId: frame.streamId,
|
||||
sequence: frame.sequence,
|
||||
kind: "windowUpdate",
|
||||
creditBytes: frame.creditBytes,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new TypeError(`Unsupported stream frame kind: ${frame.kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertStreamFrame(value: unknown): asserts value is StreamFrame {
|
||||
parseStreamFrame(value);
|
||||
}
|
||||
|
||||
function encodeBase64(bytes: Uint8Array): string {
|
||||
let output = "";
|
||||
for (let offset = 0; offset < bytes.byteLength; offset += 3) {
|
||||
const first = bytes[offset];
|
||||
const hasSecond = offset + 1 < bytes.byteLength;
|
||||
const hasThird = offset + 2 < bytes.byteLength;
|
||||
const second = hasSecond ? bytes[offset + 1] : 0;
|
||||
const third = hasThird ? bytes[offset + 2] : 0;
|
||||
output += BASE64_ALPHABET[first >> 2];
|
||||
output += BASE64_ALPHABET[((first & 0x03) << 4) | (second >> 4)];
|
||||
output += hasSecond
|
||||
? BASE64_ALPHABET[((second & 0x0f) << 2) | (third >> 6)]
|
||||
: "=";
|
||||
output += hasThird ? BASE64_ALPHABET[third & 0x3f] : "=";
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
if (value.length > PLUGIN_STREAM_MAX_BASE64_CHARACTERS) {
|
||||
throw new RangeError(
|
||||
`Stream base64 data exceeds ${PLUGIN_STREAM_MAX_BASE64_CHARACTERS} characters`,
|
||||
);
|
||||
}
|
||||
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
||||
throw new Error("Stream base64 data is not canonical RFC 4648 base64");
|
||||
}
|
||||
if (value.length === 0) return new Uint8Array(0);
|
||||
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
|
||||
const output = new Uint8Array((value.length / 4) * 3 - padding);
|
||||
let outputOffset = 0;
|
||||
for (let offset = 0; offset < value.length; offset += 4) {
|
||||
const first = BASE64_VALUE.get(value[offset]) ?? 0;
|
||||
const second = BASE64_VALUE.get(value[offset + 1]) ?? 0;
|
||||
const third = BASE64_VALUE.get(value[offset + 2]) ?? 0;
|
||||
const fourth = BASE64_VALUE.get(value[offset + 3]) ?? 0;
|
||||
if (outputOffset < output.byteLength) output[outputOffset++] = (first << 2) | (second >> 4);
|
||||
if (outputOffset < output.byteLength) output[outputOffset++] = (second << 4) | (third >> 2);
|
||||
if (outputOffset < output.byteLength) output[outputOffset++] = (third << 6) | fourth;
|
||||
}
|
||||
if (encodeBase64(output) !== value) {
|
||||
throw new Error("Stream base64 data is not canonical RFC 4648 base64");
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function decodeValidatedBase64(value: string, declaredByteLength: number): Uint8Array {
|
||||
const bytes = decodeBase64(value);
|
||||
if (bytes.byteLength !== declaredByteLength) {
|
||||
throw new Error(
|
||||
`Stream base64 byteLength mismatch: declared ${declaredByteLength}, decoded ${bytes.byteLength}`,
|
||||
);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function createBase64StreamChunk(bytes: Uint8Array): StreamChunkData {
|
||||
assertChunkByteLength(bytes.byteLength);
|
||||
return {
|
||||
encoding: "base64",
|
||||
value: encodeBase64(bytes),
|
||||
byteLength: bytes.byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
export function createJsonStreamChunk(value: JsonValue): StreamChunkData {
|
||||
const byteLength = serializedJsonByteLength(value);
|
||||
assertChunkByteLength(byteLength);
|
||||
return {
|
||||
encoding: "json",
|
||||
value,
|
||||
byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
function materializeValidatedStreamChunk(
|
||||
data: StreamChunkData,
|
||||
transfer?: ArrayBuffer,
|
||||
): MaterializedStreamChunk {
|
||||
if (data.encoding === "json") {
|
||||
if (transfer !== undefined) {
|
||||
throw new Error("JSON stream chunks must not include a transferable buffer");
|
||||
}
|
||||
assertInlineStreamChunkBytes(data);
|
||||
return { encoding: "json", value: data.value };
|
||||
}
|
||||
if (data.encoding === "base64") {
|
||||
if (transfer !== undefined) {
|
||||
throw new Error("Base64 stream chunks must not include a transferable buffer");
|
||||
}
|
||||
const bytes = decodeValidatedBase64(data.value, data.byteLength);
|
||||
return { encoding: "binary", bytes };
|
||||
}
|
||||
if (transfer === undefined) {
|
||||
throw new Error("Transfer stream chunks require an ArrayBuffer in the message envelope");
|
||||
}
|
||||
const bytes = materializeArrayBuffer(transfer);
|
||||
if (bytes.byteLength !== data.byteLength) {
|
||||
throw new Error(
|
||||
`Stream transfer byteLength mismatch: declared ${data.byteLength}, received ${bytes.byteLength}`,
|
||||
);
|
||||
}
|
||||
return { encoding: "binary", bytes };
|
||||
}
|
||||
|
||||
export function materializeStreamChunk(
|
||||
data: unknown,
|
||||
transfer?: ArrayBuffer,
|
||||
): MaterializedStreamChunk {
|
||||
return materializeValidatedStreamChunk(parseStreamChunkDataShape(data), transfer);
|
||||
}
|
||||
|
||||
export function createMessagePortStreamEnvelope(
|
||||
frame: unknown,
|
||||
transfer?: ArrayBuffer,
|
||||
): MessagePortStreamEnvelope {
|
||||
const validatedFrame = parseStreamFrame(frame);
|
||||
if (validatedFrame.kind === "chunk") {
|
||||
if (validatedFrame.data.encoding === "transfer") {
|
||||
materializeValidatedStreamChunk(validatedFrame.data, transfer);
|
||||
} else if (transfer !== undefined) {
|
||||
throw new Error("Only transfer-encoded chunk frames may include an ArrayBuffer");
|
||||
}
|
||||
} else if (transfer !== undefined) {
|
||||
throw new Error("Only transfer-encoded chunk frames may include an ArrayBuffer");
|
||||
}
|
||||
return transfer === undefined
|
||||
? { frame: validatedFrame }
|
||||
: { frame: validatedFrame, transfer };
|
||||
}
|
||||
16
packages/plugin-contract/tsconfig.build.json
Normal file
16
packages/plugin-contract/tsconfig.build.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
20
packages/plugin-sdk/package.json
Normal file
20
packages/plugin-sdk/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@netcatty/plugin-sdk",
|
||||
"version": "0.1.0-internal",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"files": ["dist"],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@netcatty/plugin-contract": "0.1.0-internal"
|
||||
}
|
||||
}
|
||||
397
packages/plugin-sdk/src/index.test.ts
Normal file
397
packages/plugin-sdk/src/index.test.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import * as ts from "typescript";
|
||||
|
||||
import {
|
||||
CancellationError,
|
||||
CancellationTokenSource,
|
||||
definePlugin,
|
||||
DisposableStore,
|
||||
PluginError,
|
||||
PLUGIN_ERROR_WIRE_CODES,
|
||||
pluginErrorToRpcError,
|
||||
throwIfCancellationRequested,
|
||||
} from "./index.ts";
|
||||
import type { PluginSecretStore, SecretRef } from "./index.ts";
|
||||
|
||||
const testSecretRef: SecretRef = {
|
||||
kind: "secret",
|
||||
id: "secret-reference-1",
|
||||
key: "token",
|
||||
};
|
||||
|
||||
const testSecretStore: PluginSecretStore = {
|
||||
async get() {
|
||||
return testSecretRef;
|
||||
},
|
||||
async set() {
|
||||
return testSecretRef;
|
||||
},
|
||||
async delete() {},
|
||||
};
|
||||
|
||||
function assertSdkTypeChecks(source: string) {
|
||||
const sdkDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const fixturePath = join(sdkDirectory, "__provider-overload-fixture.ts");
|
||||
const compilerOptions: ts.CompilerOptions = {
|
||||
allowImportingTsExtensions: true,
|
||||
module: ts.ModuleKind.NodeNext,
|
||||
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
||||
noEmit: true,
|
||||
skipLibCheck: true,
|
||||
strict: true,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
};
|
||||
const host = ts.createCompilerHost(compilerOptions, true);
|
||||
const fileExists = host.fileExists.bind(host);
|
||||
const readCompilerFile = host.readFile.bind(host);
|
||||
host.fileExists = (fileName) => fileName === fixturePath || fileExists(fileName);
|
||||
host.readFile = (fileName) => fileName === fixturePath ? source : readCompilerFile(fileName);
|
||||
|
||||
const program = ts.createProgram([fixturePath], compilerOptions, host);
|
||||
const diagnostics = ts.getPreEmitDiagnostics(program)
|
||||
.filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
|
||||
assert.deepEqual(
|
||||
diagnostics.map((diagnostic) => {
|
||||
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
|
||||
if (!diagnostic.file || diagnostic.start === undefined) {
|
||||
return `TS${diagnostic.code}: ${message}`;
|
||||
}
|
||||
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
|
||||
return `${diagnostic.file.fileName}:${line + 1}:${character + 1} TS${diagnostic.code}: ${message}`;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
test("PluginError maps stable SDK codes to stable JSON-RPC wire errors", () => {
|
||||
const error = new PluginError("permission_denied", "Approval required", { scope: "terminal" });
|
||||
assert.deepEqual(pluginErrorToRpcError(error), {
|
||||
code: -32007,
|
||||
message: "Approval required",
|
||||
data: {
|
||||
pluginCode: "permission_denied",
|
||||
details: { scope: "terminal" },
|
||||
},
|
||||
});
|
||||
assert.equal(PLUGIN_ERROR_WIRE_CODES.cancelled, -32001);
|
||||
assert.equal(PLUGIN_ERROR_WIRE_CODES.internal, -32013);
|
||||
assert.equal(new Set(Object.values(PLUGIN_ERROR_WIRE_CODES)).size, 16);
|
||||
for (const code of Object.keys(PLUGIN_ERROR_WIRE_CODES)) {
|
||||
const mapped = pluginErrorToRpcError(new PluginError(
|
||||
code as keyof typeof PLUGIN_ERROR_WIRE_CODES,
|
||||
code,
|
||||
));
|
||||
assert.equal(mapped.code, PLUGIN_ERROR_WIRE_CODES[code as keyof typeof PLUGIN_ERROR_WIRE_CODES]);
|
||||
assert.deepEqual(mapped.data, { pluginCode: code });
|
||||
}
|
||||
});
|
||||
|
||||
test("PluginError wire mapping covers the exact contract schema enums", async () => {
|
||||
const schema = JSON.parse(await readFile(
|
||||
new URL("../../plugin-contract/schema/plugin-contract.schema.json", import.meta.url),
|
||||
"utf8",
|
||||
));
|
||||
assert.deepEqual(
|
||||
Object.keys(PLUGIN_ERROR_WIRE_CODES).sort(),
|
||||
[...schema.$defs.PluginErrorName.enum].sort(),
|
||||
);
|
||||
assert.deepEqual(
|
||||
Object.values(PLUGIN_ERROR_WIRE_CODES).sort((left, right) => left - right),
|
||||
[...schema.$defs.PluginWireErrorCode.enum].sort((left, right) => left - right),
|
||||
);
|
||||
});
|
||||
|
||||
test("definePlugin preserves the exact plugin object", () => {
|
||||
const plugin = definePlugin({ activate() {} });
|
||||
assert.equal(typeof plugin.activate, "function");
|
||||
});
|
||||
|
||||
test("PluginSecretStore exposes opaque references instead of plaintext reads", async () => {
|
||||
assert.deepEqual(await testSecretStore.get("token"), testSecretRef);
|
||||
assert.deepEqual(await testSecretStore.set("token", "already-known-value"), testSecretRef);
|
||||
assert.equal("value" in testSecretRef, false);
|
||||
assert.equal(testSecretRef.key, "token");
|
||||
});
|
||||
|
||||
test("terminal interceptor typing stays specialized while broad ProviderKind helpers remain compatible", async () => {
|
||||
const source = await readFile(new URL("./index.ts", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
source,
|
||||
/kind: Exclude<\s*ProviderKind,\s*TerminalInterceptorKind \| OrdinaryTerminalProviderKind \| "connection" \| "authentication" \| "importer" \| "sync"\s*>,\s*handler: PluginProviderHandler/u,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/type ProviderHandlerForKind<[\s\S]*K extends TerminalInterceptorKind[\s\S]*TerminalInterceptorHandler/u,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/kind: K,\s*handler: ProviderHandlerForKind<NoInfer<K>, TPayload, TResult>/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("provider registrations infer typed connection importer and sync stream invocations", () => {
|
||||
assertSdkTypeChecks(`
|
||||
import { definePlugin } from "./index.ts";
|
||||
import type {
|
||||
ConnectionProviderHandler,
|
||||
ConnectionProviderResultByOperation,
|
||||
AuthenticationResult,
|
||||
ImporterKeyDraft,
|
||||
ImporterProviderHandler,
|
||||
SyncProviderHandler,
|
||||
SyncProviderResultByOperation,
|
||||
} from "./index.ts";
|
||||
|
||||
const resizeAck: ConnectionProviderResultByOperation["resize"] = null;
|
||||
void resizeAck;
|
||||
// @ts-expect-error connection control operations acknowledge with JSON null, never object payloads.
|
||||
const invalidResizeAck: ConnectionProviderResultByOperation["resize"] = { ok: true };
|
||||
void invalidResizeAck;
|
||||
|
||||
const invalidConnectionProvider: ConnectionProviderHandler = {
|
||||
validateConfiguration: () => ({ valid: true, issues: [] }),
|
||||
probe: () => ({ available: true }),
|
||||
open: () => ({ connectionId: "connection-1", status: "connected" }),
|
||||
// @ts-expect-error resize must return the resize control acknowledgement, not a probe result.
|
||||
resize: () => ({ available: true }),
|
||||
signal: () => null,
|
||||
reconnect: () => null,
|
||||
close: () => null,
|
||||
getStatus: () => ({ status: "connected" }),
|
||||
};
|
||||
void invalidConnectionProvider;
|
||||
|
||||
const inlineImporterKey: ImporterKeyDraft = {
|
||||
label: "Inline key",
|
||||
type: "ED25519",
|
||||
privateKey: "private",
|
||||
};
|
||||
const fileImporterKey: ImporterKeyDraft = {
|
||||
label: "File key",
|
||||
type: "ED25519",
|
||||
filePath: "/keys/id_ed25519",
|
||||
};
|
||||
void inlineImporterKey;
|
||||
void fileImporterKey;
|
||||
// @ts-expect-error runtime validation requires exactly one key source.
|
||||
const ambiguousImporterKey: ImporterKeyDraft = {
|
||||
label: "Ambiguous key",
|
||||
type: "ED25519",
|
||||
privateKey: "private",
|
||||
filePath: "/keys/id_ed25519",
|
||||
};
|
||||
void ambiguousImporterKey;
|
||||
|
||||
const invalidImporterProvider: ImporterProviderHandler = {
|
||||
// @ts-expect-error detect must return a detection result, not parse counters.
|
||||
detect: () => ({ parsed: 0, warnings: 0, errors: 0 }),
|
||||
parse: () => ({ parsed: 0, warnings: 0, errors: 0 }),
|
||||
};
|
||||
void invalidImporterProvider;
|
||||
|
||||
const disconnectAck: SyncProviderResultByOperation["disconnect"] = null;
|
||||
void disconnectAck;
|
||||
// @ts-expect-error disconnect acknowledges with JSON null.
|
||||
const invalidDisconnectAck: SyncProviderResultByOperation["disconnect"] = { ok: true };
|
||||
void invalidDisconnectAck;
|
||||
|
||||
const invalidSyncProvider: SyncProviderHandler = {
|
||||
connect: () => ({ account: { id: "a" } }),
|
||||
disconnect: () => null,
|
||||
getAccount: () => ({ account: null }),
|
||||
getCapabilities: () => ({ revisions: true, conditionalWrites: true, atomicReplacement: true }),
|
||||
// @ts-expect-error readObject must return a SyncReadObjectResult, not write result.
|
||||
readObject: () => ({ created: true }),
|
||||
writeObject: () => ({ created: true }),
|
||||
deleteObject: () => ({ deleted: true }),
|
||||
};
|
||||
void invalidSyncProvider;
|
||||
|
||||
// @ts-expect-error challenge results must include the exact challenge payload.
|
||||
const incompleteAuthenticationResult: AuthenticationResult = { status: "challenge" };
|
||||
void incompleteAuthenticationResult;
|
||||
|
||||
definePlugin({
|
||||
activate(context) {
|
||||
context.providers.register("com.example.connection", "connection", {
|
||||
async open(invocation) {
|
||||
const input = await invocation.input;
|
||||
const chunk: Uint8Array | null = await input.read();
|
||||
if (chunk) {
|
||||
await invocation.output.write(chunk);
|
||||
}
|
||||
await invocation.output.end();
|
||||
return { connectionId: "connection-1", status: "connected" };
|
||||
},
|
||||
validateConfiguration(invocation) {
|
||||
const configuration = invocation.payload.configuration;
|
||||
void configuration;
|
||||
return { valid: true, issues: [] };
|
||||
},
|
||||
probe() {
|
||||
return { available: true };
|
||||
},
|
||||
resize() {
|
||||
return null;
|
||||
},
|
||||
signal() {
|
||||
return null;
|
||||
},
|
||||
reconnect() {
|
||||
return null;
|
||||
},
|
||||
close() {
|
||||
return null;
|
||||
},
|
||||
getStatus() {
|
||||
return {
|
||||
status: "connected",
|
||||
diagnostics: [{ severity: "warning", message: "using fallback host key algorithm" }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-expect-error connection Providers use operation-keyed handlers so each operation has its exact result.
|
||||
context.providers.register("com.example.connection.invalid", "connection", async () => ({ available: true }));
|
||||
|
||||
context.providers.register("com.example.importer", "importer", {
|
||||
async parse(invocation) {
|
||||
const input = await invocation.input;
|
||||
await invocation.output.write(new Uint8Array([65]));
|
||||
await input.read();
|
||||
return { parsed: 0, warnings: 0, errors: 0 };
|
||||
},
|
||||
detect(invocation) {
|
||||
const sampleData: string = invocation.payload.sample.data;
|
||||
void sampleData;
|
||||
return { confidence: 1 };
|
||||
},
|
||||
});
|
||||
|
||||
context.providers.register("com.example.sync", "sync", {
|
||||
connect(invocation) {
|
||||
void invocation.payload.configuration;
|
||||
return { account: { id: "acct" } };
|
||||
},
|
||||
disconnect() {
|
||||
return null;
|
||||
},
|
||||
getAccount() {
|
||||
return { account: { id: "acct" } };
|
||||
},
|
||||
getCapabilities() {
|
||||
return {
|
||||
revisions: true,
|
||||
conditionalWrites: true,
|
||||
atomicReplacement: true,
|
||||
maxObjectBytes: 1024,
|
||||
};
|
||||
},
|
||||
async readObject(invocation) {
|
||||
if (invocation.output) {
|
||||
await invocation.output.write(new Uint8Array([1, 2, 3]));
|
||||
await invocation.output.end();
|
||||
return { found: true, byteLength: 3, streamed: true, revision: "r1" };
|
||||
}
|
||||
return {
|
||||
found: true,
|
||||
byteLength: 3,
|
||||
encoding: "base64",
|
||||
data: "AQID",
|
||||
revision: "r1",
|
||||
};
|
||||
},
|
||||
async writeObject(invocation) {
|
||||
if (invocation.input) {
|
||||
const stream = await invocation.input;
|
||||
await stream.read();
|
||||
}
|
||||
return { created: true, revision: "r2" };
|
||||
},
|
||||
deleteObject() {
|
||||
return { deleted: true };
|
||||
},
|
||||
});
|
||||
|
||||
// @ts-expect-error sync Providers use operation-keyed handlers.
|
||||
context.providers.register("com.example.sync.invalid", "sync", async () => ({ account: { id: "x" } }));
|
||||
},
|
||||
});
|
||||
`);
|
||||
});
|
||||
|
||||
test("DisposableStore disposes every item once", () => {
|
||||
const store = new DisposableStore();
|
||||
const calls: string[] = [];
|
||||
store.add({ dispose: () => calls.push("first") });
|
||||
store.add({ dispose: () => calls.push("second") });
|
||||
|
||||
store.dispose();
|
||||
store.dispose();
|
||||
|
||||
assert.deepEqual(calls, ["first", "second"]);
|
||||
});
|
||||
|
||||
test("DisposableStore disposes rejected late additions", () => {
|
||||
const store = new DisposableStore();
|
||||
store.dispose();
|
||||
let disposed = false;
|
||||
|
||||
assert.throws(
|
||||
() => store.add({ dispose: () => { disposed = true; } }),
|
||||
(error) => error instanceof PluginError && error.code === "unavailable",
|
||||
);
|
||||
assert.equal(disposed, true);
|
||||
});
|
||||
|
||||
test("CancellationTokenSource notifies listeners once", () => {
|
||||
const source = new CancellationTokenSource();
|
||||
let count = 0;
|
||||
source.token.onCancellationRequested(() => count += 1);
|
||||
|
||||
source.cancel();
|
||||
source.cancel();
|
||||
|
||||
assert.equal(count, 1);
|
||||
assert.equal(source.token.isCancellationRequested, true);
|
||||
assert.throws(
|
||||
() => throwIfCancellationRequested(source.token),
|
||||
CancellationError,
|
||||
);
|
||||
});
|
||||
|
||||
test("CancellationTokenSource notifies every listener before reporting failures", () => {
|
||||
const source = new CancellationTokenSource();
|
||||
const calls: string[] = [];
|
||||
source.token.onCancellationRequested(() => {
|
||||
calls.push("failing");
|
||||
throw new Error("listener failed");
|
||||
});
|
||||
source.token.onCancellationRequested(() => calls.push("surviving"));
|
||||
|
||||
assert.throws(
|
||||
() => source.cancel(),
|
||||
(error) => error instanceof AggregateError
|
||||
&& error.errors.length === 1
|
||||
&& error.errors[0] instanceof Error
|
||||
&& error.errors[0].message === "listener failed",
|
||||
);
|
||||
assert.deepEqual(calls, ["failing", "surviving"]);
|
||||
assert.equal(source.token.isCancellationRequested, true);
|
||||
assert.doesNotThrow(() => source.cancel());
|
||||
});
|
||||
|
||||
test("CancellationTokenSource finishes disposal when a cancellation listener fails", () => {
|
||||
const source = new CancellationTokenSource();
|
||||
source.token.onCancellationRequested(() => {
|
||||
throw new Error("listener failed");
|
||||
});
|
||||
|
||||
assert.throws(() => source.dispose(true), AggregateError);
|
||||
assert.doesNotThrow(() => source.dispose(true));
|
||||
});
|
||||
924
packages/plugin-sdk/src/index.ts
Normal file
924
packages/plugin-sdk/src/index.ts
Normal file
@@ -0,0 +1,924 @@
|
||||
import type {
|
||||
AuthenticationBeginPayload,
|
||||
AuthenticationResponsePayload,
|
||||
AuthenticationResult,
|
||||
ConnectionConfigurationPayload,
|
||||
ConnectionControlResult,
|
||||
ConnectionControlPayload,
|
||||
ConnectionOpenPayload,
|
||||
ConnectionOpenResult,
|
||||
ConnectionProbeResult,
|
||||
ConnectionResizePayload,
|
||||
ConnectionSignalPayload,
|
||||
ConnectionStatusResult,
|
||||
ConnectionValidateResult,
|
||||
CredentialRef,
|
||||
FeatureId,
|
||||
ImporterDetectPayload,
|
||||
ImporterDetectResult,
|
||||
ImporterParsePayload,
|
||||
ImporterParseResult,
|
||||
JsonValue,
|
||||
PluginErrorData,
|
||||
PluginErrorName,
|
||||
PluginId,
|
||||
ProviderKind,
|
||||
PluginWireErrorCode,
|
||||
RpcErrorObject,
|
||||
SecretLeaseRef,
|
||||
SecretRef,
|
||||
SemanticVersion,
|
||||
SyncCapabilitiesResult,
|
||||
SyncConnectPayload,
|
||||
SyncConnectResult,
|
||||
SyncDeleteObjectPayload,
|
||||
SyncDeleteObjectResult,
|
||||
SyncDisconnectPayload,
|
||||
SyncDisconnectResult,
|
||||
SyncGetAccountPayload,
|
||||
SyncGetAccountResult,
|
||||
SyncGetCapabilitiesPayload,
|
||||
SyncReadObjectPayload,
|
||||
SyncReadObjectResult,
|
||||
SyncWriteObjectPayload,
|
||||
SyncWriteObjectResult,
|
||||
TerminalSessionSnapshot,
|
||||
} from "@netcatty/plugin-contract";
|
||||
|
||||
export type * from "@netcatty/plugin-contract";
|
||||
|
||||
export interface Disposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type CancellationListener = () => void;
|
||||
|
||||
export interface CancellationToken {
|
||||
readonly isCancellationRequested: boolean;
|
||||
onCancellationRequested(listener: CancellationListener): Disposable;
|
||||
}
|
||||
|
||||
export interface PluginLogger {
|
||||
debug(message: string, fields?: Readonly<Record<string, JsonValue>>): void;
|
||||
info(message: string, fields?: Readonly<Record<string, JsonValue>>): void;
|
||||
warn(message: string, fields?: Readonly<Record<string, JsonValue>>): void;
|
||||
error(message: string, fields?: Readonly<Record<string, JsonValue>>): void;
|
||||
}
|
||||
|
||||
export interface PluginKeyValueStore {
|
||||
get<T extends JsonValue>(key: string): Promise<T | undefined>;
|
||||
set(key: string, value: JsonValue): Promise<void>;
|
||||
delete(key: string): Promise<void>;
|
||||
keys(): Promise<readonly string[]>;
|
||||
}
|
||||
|
||||
export interface PluginSecretStore {
|
||||
get(key: string): Promise<SecretRef | undefined>;
|
||||
set(key: string, value: string): Promise<SecretRef>;
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginSettingOptions {
|
||||
readonly scopeId?: string;
|
||||
}
|
||||
|
||||
export interface PluginSettingChangeEvent {
|
||||
readonly settingId: string;
|
||||
readonly scope: string;
|
||||
readonly scopeId: string;
|
||||
readonly source: "host" | "plugin";
|
||||
}
|
||||
|
||||
export interface PluginSettings {
|
||||
get<T extends JsonValue | SecretRef>(settingId: string, options?: PluginSettingOptions): Promise<T | undefined>;
|
||||
update(settingId: string, value: JsonValue, options?: PluginSettingOptions): Promise<Readonly<{ restartRequired: boolean }>>;
|
||||
onDidChange(listener: (event: PluginSettingChangeEvent) => void): Disposable;
|
||||
}
|
||||
|
||||
export interface PluginCommandInvocation {
|
||||
readonly source: "host" | "plugin" | string;
|
||||
readonly context?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
export type PluginCommandHandler = (args: JsonValue | undefined, invocation: PluginCommandInvocation) => JsonValue | void | Promise<JsonValue | void>;
|
||||
|
||||
export interface PluginCommands {
|
||||
registerCommand(commandId: string, handler: PluginCommandHandler): Disposable;
|
||||
executeCommand<T extends JsonValue = JsonValue>(commandId: string, args?: JsonValue): Promise<T>;
|
||||
}
|
||||
|
||||
export interface PluginContextKeys {
|
||||
set(key: string, value: JsonValue): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginViews {
|
||||
onDidReceiveMessage(viewId: string, listener: (message: JsonValue) => void): Disposable;
|
||||
postMessage(viewId: string, message: JsonValue): void;
|
||||
getState<T extends JsonValue = JsonValue>(viewId: string, scopeId: string): Promise<T | undefined>;
|
||||
setState(viewId: string, scopeId: string, state: JsonValue): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginProviderInvocation<TPayload extends JsonValue = JsonValue> {
|
||||
readonly providerId: string;
|
||||
readonly kind: ProviderKind;
|
||||
readonly operation: string;
|
||||
readonly requestId: string;
|
||||
readonly payload: TPayload | undefined;
|
||||
readonly deadlineMs: number | undefined;
|
||||
readonly cancellationToken: CancellationToken;
|
||||
}
|
||||
|
||||
export type PluginProviderHandler<
|
||||
TPayload extends JsonValue = JsonValue,
|
||||
TResult extends JsonValue = JsonValue,
|
||||
> = (invocation: PluginProviderInvocation<TPayload>) => TResult | void | Promise<TResult | void>;
|
||||
|
||||
type TypedPluginProviderInvocation<TPayload> = Omit<PluginProviderInvocation, "payload"> & {
|
||||
readonly payload: TPayload;
|
||||
};
|
||||
|
||||
type ProviderHandlerForKind<
|
||||
K extends ProviderKind,
|
||||
TPayload extends JsonValue,
|
||||
TResult extends JsonValue,
|
||||
> = K extends TerminalInterceptorKind
|
||||
? TerminalInterceptorHandler
|
||||
: K extends OrdinaryTerminalProviderKind
|
||||
? OrdinaryTerminalProviderHandler<K>
|
||||
: K extends "connection"
|
||||
? ConnectionProviderHandler
|
||||
: K extends "authentication"
|
||||
? AuthenticationProviderHandler
|
||||
: K extends "importer"
|
||||
? ImporterProviderHandler
|
||||
: K extends "sync"
|
||||
? SyncProviderHandler
|
||||
: PluginProviderHandler<TPayload, TResult>;
|
||||
|
||||
export interface PluginProviders {
|
||||
register<K extends OrdinaryTerminalProviderKind>(
|
||||
providerId: string,
|
||||
kind: K,
|
||||
handler: OrdinaryTerminalProviderHandler<K>,
|
||||
): Disposable;
|
||||
register(
|
||||
providerId: string,
|
||||
kind: TerminalInterceptorKind,
|
||||
handler: TerminalInterceptorHandler,
|
||||
): Disposable;
|
||||
register(
|
||||
providerId: string,
|
||||
kind: "connection",
|
||||
handler: ConnectionProviderHandler,
|
||||
): Disposable;
|
||||
register(
|
||||
providerId: string,
|
||||
kind: "authentication",
|
||||
handler: AuthenticationProviderHandler,
|
||||
): Disposable;
|
||||
register(
|
||||
providerId: string,
|
||||
kind: "importer",
|
||||
handler: ImporterProviderHandler,
|
||||
): Disposable;
|
||||
register(
|
||||
providerId: string,
|
||||
kind: "sync",
|
||||
handler: SyncProviderHandler,
|
||||
): Disposable;
|
||||
register<TPayload extends JsonValue = JsonValue, TResult extends JsonValue = JsonValue>(
|
||||
providerId: string,
|
||||
kind: Exclude<
|
||||
ProviderKind,
|
||||
TerminalInterceptorKind | OrdinaryTerminalProviderKind | "connection" | "authentication" | "importer" | "sync"
|
||||
>,
|
||||
handler: PluginProviderHandler<TPayload, TResult>,
|
||||
): Disposable;
|
||||
register<
|
||||
K extends ProviderKind,
|
||||
TPayload extends JsonValue = JsonValue,
|
||||
TResult extends JsonValue = JsonValue,
|
||||
>(
|
||||
providerId: string,
|
||||
kind: K,
|
||||
handler: ProviderHandlerForKind<NoInfer<K>, TPayload, TResult>,
|
||||
): Disposable;
|
||||
}
|
||||
|
||||
export type TerminalInterceptorKind = "terminal.interceptor.input" | "terminal.interceptor.output";
|
||||
|
||||
export interface TerminalInterceptorInvocation {
|
||||
readonly providerId: string;
|
||||
readonly kind: TerminalInterceptorKind;
|
||||
readonly direction: "input" | "output";
|
||||
readonly sequence: number;
|
||||
readonly session: TerminalSessionSnapshot;
|
||||
/** UTF-8 terminal data. The buffer is owned by this invocation. */
|
||||
readonly data: Uint8Array;
|
||||
}
|
||||
|
||||
export type TerminalInterceptorHandler = (
|
||||
invocation: TerminalInterceptorInvocation,
|
||||
) => Uint8Array | ArrayBuffer | Promise<Uint8Array | ArrayBuffer>;
|
||||
|
||||
export interface TerminalSessionEvent {
|
||||
readonly type:
|
||||
| "snapshot"
|
||||
| "created"
|
||||
| "connected"
|
||||
| "reconnected"
|
||||
| "cwdChanged"
|
||||
| "titleChanged"
|
||||
| "resized"
|
||||
| "alternateScreenChanged"
|
||||
| "commandSubmitted"
|
||||
| "commandCompleted"
|
||||
| "disconnected"
|
||||
| "disposed";
|
||||
readonly session: TerminalSessionSnapshot;
|
||||
readonly exitCode?: number;
|
||||
}
|
||||
|
||||
export interface TerminalProviderPayload {
|
||||
/** Immutable host snapshot bound to this exact invocation. */
|
||||
readonly session: TerminalSessionSnapshot;
|
||||
}
|
||||
|
||||
export interface TerminalCompletionPayload extends TerminalProviderPayload {
|
||||
readonly input: string;
|
||||
readonly cursor: number;
|
||||
readonly hostOs: "linux" | "windows" | "macos";
|
||||
readonly cwdSource: "prompt" | "fallback" | "none" | null;
|
||||
readonly maximum: number;
|
||||
}
|
||||
|
||||
export interface TerminalCompletionItem {
|
||||
readonly text: string;
|
||||
/** When supplied, it must equal text; the host always displays the inserted command. */
|
||||
readonly displayText?: string;
|
||||
readonly description?: string;
|
||||
readonly score?: number;
|
||||
}
|
||||
|
||||
export interface TerminalCompletionResult {
|
||||
readonly items: readonly TerminalCompletionItem[];
|
||||
}
|
||||
|
||||
export interface TerminalDecorationPayload extends TerminalProviderPayload {
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export interface TerminalDecorationRule {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly patterns: readonly string[];
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
export interface TerminalDecorationResult {
|
||||
readonly rules: readonly TerminalDecorationRule[];
|
||||
}
|
||||
|
||||
export interface TerminalTextRange {
|
||||
readonly start: number;
|
||||
readonly length: number;
|
||||
}
|
||||
|
||||
export interface TerminalLinkItem extends TerminalTextRange {
|
||||
readonly uri: string;
|
||||
readonly label?: string;
|
||||
}
|
||||
|
||||
export interface TerminalLineProviderPayload extends TerminalProviderPayload {
|
||||
readonly line: string;
|
||||
readonly bufferLineNumber: number;
|
||||
}
|
||||
|
||||
export interface TerminalLinkResult {
|
||||
readonly links: readonly TerminalLinkItem[];
|
||||
}
|
||||
|
||||
export interface TerminalHoverItem extends TerminalTextRange {
|
||||
readonly contents: string;
|
||||
}
|
||||
|
||||
export interface TerminalHoverResult {
|
||||
readonly hovers: readonly TerminalHoverItem[];
|
||||
}
|
||||
|
||||
export interface TerminalMatcherLine {
|
||||
readonly lineId: string;
|
||||
readonly line: string;
|
||||
readonly bufferLineNumber: number;
|
||||
}
|
||||
|
||||
export interface TerminalMatcherPayload extends TerminalProviderPayload {
|
||||
readonly lines: readonly TerminalMatcherLine[];
|
||||
}
|
||||
|
||||
export interface TerminalOutputMatchItem extends TerminalTextRange {
|
||||
/** Host-provided line identifier from the provideMatches request batch. */
|
||||
readonly lineId: string;
|
||||
readonly label: string;
|
||||
readonly severity?: "info" | "warning" | "error" | "success";
|
||||
readonly color?: string;
|
||||
}
|
||||
|
||||
export interface TerminalMatcherResult {
|
||||
readonly matches: readonly TerminalOutputMatchItem[];
|
||||
}
|
||||
|
||||
export interface TerminalAnnotationItem {
|
||||
readonly text: string;
|
||||
readonly color?: string;
|
||||
}
|
||||
|
||||
export interface TerminalSemanticResult {
|
||||
readonly classification?: string;
|
||||
readonly description?: string;
|
||||
readonly destructive?: boolean;
|
||||
readonly idempotent?: boolean;
|
||||
readonly annotations?: readonly TerminalAnnotationItem[];
|
||||
}
|
||||
|
||||
export interface TerminalSemanticPayload extends TerminalProviderPayload {
|
||||
readonly command: string;
|
||||
}
|
||||
|
||||
export interface TerminalPromptPayload extends TerminalProviderPayload {
|
||||
readonly reason: "commandCompleted";
|
||||
readonly promptLine?: string;
|
||||
readonly bufferLineNumber?: number;
|
||||
}
|
||||
|
||||
export interface TerminalPromptResult {
|
||||
readonly annotations: readonly TerminalAnnotationItem[];
|
||||
}
|
||||
|
||||
export interface TerminalBackgroundLayer {
|
||||
readonly id: string;
|
||||
readonly color: string;
|
||||
/** Defaults to a host-owned safe opacity of 0.15. */
|
||||
readonly opacity?: number;
|
||||
}
|
||||
|
||||
export interface TerminalBackgroundResult {
|
||||
readonly layers: readonly TerminalBackgroundLayer[];
|
||||
/** Optional bounded host refresh cadence. The host clamps this to 250-60000 ms. */
|
||||
readonly refreshAfterMs?: number;
|
||||
}
|
||||
|
||||
export interface TerminalBackgroundPayload extends TerminalProviderPayload {
|
||||
readonly reason: string;
|
||||
readonly terminalBackground?: string;
|
||||
}
|
||||
|
||||
export type TerminalThemeColorName =
|
||||
| "background" | "foreground" | "cursor" | "selection"
|
||||
| "black" | "red" | "green" | "yellow" | "blue" | "magenta" | "cyan" | "white"
|
||||
| "brightBlack" | "brightRed" | "brightGreen" | "brightYellow"
|
||||
| "brightBlue" | "brightMagenta" | "brightCyan" | "brightWhite";
|
||||
|
||||
export interface TerminalThemePayload extends TerminalProviderPayload {
|
||||
readonly reason: string;
|
||||
readonly currentTheme: {
|
||||
readonly type: "dark" | "light";
|
||||
readonly colors: Readonly<Record<TerminalThemeColorName, string>>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TerminalThemeResult {
|
||||
readonly colors: Readonly<Partial<Record<TerminalThemeColorName, string>>>;
|
||||
}
|
||||
|
||||
export interface OrdinaryTerminalProviderPayloadByKind {
|
||||
readonly "terminal.completion": TerminalCompletionPayload;
|
||||
readonly "terminal.decoration": TerminalDecorationPayload;
|
||||
readonly "terminal.link": TerminalLineProviderPayload;
|
||||
readonly "terminal.hover": TerminalLineProviderPayload;
|
||||
readonly "terminal.matcher": TerminalMatcherPayload;
|
||||
readonly "terminal.semantic": TerminalSemanticPayload;
|
||||
readonly "terminal.prompt": TerminalPromptPayload;
|
||||
readonly "terminal.background": TerminalBackgroundPayload;
|
||||
readonly "terminal.theme": TerminalThemePayload;
|
||||
}
|
||||
|
||||
export interface OrdinaryTerminalProviderResultByKind {
|
||||
readonly "terminal.completion": TerminalCompletionResult;
|
||||
readonly "terminal.decoration": TerminalDecorationResult;
|
||||
readonly "terminal.link": TerminalLinkResult;
|
||||
readonly "terminal.hover": TerminalHoverResult;
|
||||
readonly "terminal.matcher": TerminalMatcherResult;
|
||||
readonly "terminal.semantic": TerminalSemanticResult;
|
||||
readonly "terminal.prompt": TerminalPromptResult;
|
||||
readonly "terminal.background": TerminalBackgroundResult;
|
||||
readonly "terminal.theme": TerminalThemeResult;
|
||||
}
|
||||
|
||||
export interface OrdinaryTerminalProviderOperationByKind {
|
||||
readonly "terminal.completion": "provideCompletions";
|
||||
readonly "terminal.decoration": "provideDecorations";
|
||||
readonly "terminal.link": "provideLinks";
|
||||
readonly "terminal.hover": "provideHovers";
|
||||
readonly "terminal.matcher": "provideMatches";
|
||||
readonly "terminal.semantic": "provideSemantics";
|
||||
readonly "terminal.prompt": "provideAnnotations";
|
||||
readonly "terminal.background": "provideBackgrounds";
|
||||
readonly "terminal.theme": "provideTheme";
|
||||
}
|
||||
|
||||
export type OrdinaryTerminalProviderKind = keyof OrdinaryTerminalProviderPayloadByKind;
|
||||
|
||||
export interface OrdinaryTerminalProviderInvocation<K extends OrdinaryTerminalProviderKind> {
|
||||
readonly providerId: string;
|
||||
readonly kind: K;
|
||||
readonly operation: OrdinaryTerminalProviderOperationByKind[K];
|
||||
readonly requestId: string;
|
||||
readonly payload: OrdinaryTerminalProviderPayloadByKind[K];
|
||||
readonly deadlineMs: number | undefined;
|
||||
readonly cancellationToken: CancellationToken;
|
||||
}
|
||||
|
||||
export type OrdinaryTerminalProviderHandler<K extends OrdinaryTerminalProviderKind> = (
|
||||
invocation: OrdinaryTerminalProviderInvocation<K>,
|
||||
) => OrdinaryTerminalProviderResultByKind[K] | Promise<OrdinaryTerminalProviderResultByKind[K]>;
|
||||
|
||||
export interface ConnectionProviderInvocationByOperation {
|
||||
readonly validateConfiguration: TypedPluginProviderInvocation<ConnectionConfigurationPayload> & {
|
||||
readonly kind: "connection";
|
||||
readonly operation: "validateConfiguration";
|
||||
};
|
||||
readonly probe: TypedPluginProviderInvocation<ConnectionConfigurationPayload> & {
|
||||
readonly kind: "connection";
|
||||
readonly operation: "probe";
|
||||
};
|
||||
readonly open: TypedPluginProviderInvocation<ConnectionOpenPayload> & {
|
||||
readonly kind: "connection";
|
||||
readonly operation: "open";
|
||||
readonly input: Promise<PluginReadableByteStream>;
|
||||
readonly output: PluginWritableByteStream;
|
||||
};
|
||||
readonly resize: TypedPluginProviderInvocation<ConnectionResizePayload> & {
|
||||
readonly kind: "connection";
|
||||
readonly operation: "resize";
|
||||
};
|
||||
readonly signal: TypedPluginProviderInvocation<ConnectionSignalPayload> & {
|
||||
readonly kind: "connection";
|
||||
readonly operation: "signal";
|
||||
};
|
||||
readonly reconnect: TypedPluginProviderInvocation<ConnectionControlPayload> & {
|
||||
readonly kind: "connection";
|
||||
readonly operation: "reconnect";
|
||||
};
|
||||
readonly close: TypedPluginProviderInvocation<ConnectionControlPayload> & {
|
||||
readonly kind: "connection";
|
||||
readonly operation: "close";
|
||||
};
|
||||
readonly getStatus: TypedPluginProviderInvocation<ConnectionControlPayload> & {
|
||||
readonly kind: "connection";
|
||||
readonly operation: "getStatus";
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConnectionProviderResultByOperation {
|
||||
readonly validateConfiguration: ConnectionValidateResult;
|
||||
readonly probe: ConnectionProbeResult;
|
||||
readonly open: ConnectionOpenResult;
|
||||
readonly resize: ConnectionControlResult;
|
||||
readonly signal: ConnectionControlResult;
|
||||
readonly reconnect: ConnectionControlResult;
|
||||
readonly close: ConnectionControlResult;
|
||||
readonly getStatus: ConnectionStatusResult;
|
||||
}
|
||||
|
||||
export type ConnectionProviderOperation = keyof ConnectionProviderInvocationByOperation;
|
||||
|
||||
export type ConnectionProviderInvocation =
|
||||
ConnectionProviderInvocationByOperation[ConnectionProviderOperation];
|
||||
|
||||
export type ConnectionProviderResult =
|
||||
ConnectionProviderResultByOperation[ConnectionProviderOperation];
|
||||
|
||||
export type ConnectionProviderOperationHandler<TOperation extends ConnectionProviderOperation> = (
|
||||
invocation: ConnectionProviderInvocationByOperation[TOperation],
|
||||
) => ConnectionProviderResultByOperation[TOperation] | Promise<ConnectionProviderResultByOperation[TOperation]>;
|
||||
|
||||
export type ConnectionProviderHandler = Readonly<{
|
||||
[TOperation in ConnectionProviderOperation]: ConnectionProviderOperationHandler<TOperation>;
|
||||
}>;
|
||||
|
||||
export type AuthenticationProviderInvocation =
|
||||
| (TypedPluginProviderInvocation<AuthenticationBeginPayload> & {
|
||||
readonly kind: "authentication";
|
||||
readonly operation: "begin";
|
||||
})
|
||||
| (TypedPluginProviderInvocation<AuthenticationResponsePayload> & {
|
||||
readonly kind: "authentication";
|
||||
readonly operation: "respond";
|
||||
})
|
||||
| (TypedPluginProviderInvocation<Readonly<{ operationId: string }>> & {
|
||||
readonly kind: "authentication";
|
||||
readonly operation: "cancel";
|
||||
});
|
||||
|
||||
export type AuthenticationProviderHandler = (
|
||||
invocation: AuthenticationProviderInvocation,
|
||||
) => AuthenticationResult | Promise<AuthenticationResult>;
|
||||
|
||||
export interface ImporterProviderInvocationByOperation {
|
||||
readonly detect: TypedPluginProviderInvocation<ImporterDetectPayload> & {
|
||||
readonly kind: "importer";
|
||||
readonly operation: "detect";
|
||||
};
|
||||
readonly parse: TypedPluginProviderInvocation<ImporterParsePayload> & {
|
||||
readonly kind: "importer";
|
||||
readonly operation: "parse";
|
||||
readonly input: Promise<PluginReadableByteStream>;
|
||||
readonly output: PluginWritableByteStream;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImporterProviderResultByOperation {
|
||||
readonly detect: ImporterDetectResult;
|
||||
readonly parse: ImporterParseResult;
|
||||
}
|
||||
|
||||
export type ImporterProviderOperation = keyof ImporterProviderInvocationByOperation;
|
||||
export type ImporterProviderInvocation =
|
||||
ImporterProviderInvocationByOperation[ImporterProviderOperation];
|
||||
export type ImporterProviderResult =
|
||||
ImporterProviderResultByOperation[ImporterProviderOperation];
|
||||
export type ImporterProviderOperationHandler<TOperation extends ImporterProviderOperation> = (
|
||||
invocation: ImporterProviderInvocationByOperation[TOperation],
|
||||
) => ImporterProviderResultByOperation[TOperation] | Promise<ImporterProviderResultByOperation[TOperation]>;
|
||||
export type ImporterProviderHandler = Readonly<{
|
||||
[TOperation in ImporterProviderOperation]: ImporterProviderOperationHandler<TOperation>;
|
||||
}>;
|
||||
|
||||
export interface SyncProviderInvocationByOperation {
|
||||
readonly connect: TypedPluginProviderInvocation<SyncConnectPayload> & {
|
||||
readonly kind: "sync";
|
||||
readonly operation: "connect";
|
||||
};
|
||||
readonly disconnect: TypedPluginProviderInvocation<SyncDisconnectPayload | undefined> & {
|
||||
readonly kind: "sync";
|
||||
readonly operation: "disconnect";
|
||||
};
|
||||
readonly getAccount: TypedPluginProviderInvocation<SyncGetAccountPayload | undefined> & {
|
||||
readonly kind: "sync";
|
||||
readonly operation: "getAccount";
|
||||
};
|
||||
readonly getCapabilities: TypedPluginProviderInvocation<SyncGetCapabilitiesPayload | undefined> & {
|
||||
readonly kind: "sync";
|
||||
readonly operation: "getCapabilities";
|
||||
};
|
||||
readonly readObject: TypedPluginProviderInvocation<SyncReadObjectPayload> & {
|
||||
readonly kind: "sync";
|
||||
readonly operation: "readObject";
|
||||
readonly output?: PluginWritableByteStream;
|
||||
};
|
||||
readonly writeObject: TypedPluginProviderInvocation<SyncWriteObjectPayload> & {
|
||||
readonly kind: "sync";
|
||||
readonly operation: "writeObject";
|
||||
readonly input?: Promise<PluginReadableByteStream>;
|
||||
};
|
||||
readonly deleteObject: TypedPluginProviderInvocation<SyncDeleteObjectPayload> & {
|
||||
readonly kind: "sync";
|
||||
readonly operation: "deleteObject";
|
||||
};
|
||||
}
|
||||
|
||||
export interface SyncProviderResultByOperation {
|
||||
readonly connect: SyncConnectResult;
|
||||
readonly disconnect: SyncDisconnectResult;
|
||||
readonly getAccount: SyncGetAccountResult;
|
||||
readonly getCapabilities: SyncCapabilitiesResult;
|
||||
readonly readObject: SyncReadObjectResult;
|
||||
readonly writeObject: SyncWriteObjectResult;
|
||||
readonly deleteObject: SyncDeleteObjectResult;
|
||||
}
|
||||
|
||||
export type SyncProviderOperation = keyof SyncProviderInvocationByOperation;
|
||||
export type SyncProviderInvocation =
|
||||
SyncProviderInvocationByOperation[SyncProviderOperation];
|
||||
export type SyncProviderResult =
|
||||
SyncProviderResultByOperation[SyncProviderOperation];
|
||||
export type SyncProviderOperationHandler<TOperation extends SyncProviderOperation> = (
|
||||
invocation: SyncProviderInvocationByOperation[TOperation],
|
||||
) => SyncProviderResultByOperation[TOperation] | Promise<SyncProviderResultByOperation[TOperation]>;
|
||||
export type SyncProviderHandler = Readonly<{
|
||||
[TOperation in SyncProviderOperation]: SyncProviderOperationHandler<TOperation>;
|
||||
}>;
|
||||
|
||||
export interface PluginTerminalSessions {
|
||||
onDidChange(listener: (event: TerminalSessionEvent) => void): Disposable;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentChangeEvent {
|
||||
readonly locale: string;
|
||||
readonly theme: string;
|
||||
readonly reducedMotion: boolean;
|
||||
readonly highContrast: boolean;
|
||||
readonly themeTokens: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface PluginEnvironment extends PluginEnvironmentChangeEvent {
|
||||
onDidChange(listener: (event: PluginEnvironmentChangeEvent) => void): Disposable;
|
||||
}
|
||||
|
||||
export interface PluginCredentialLeaseOptions {
|
||||
readonly operationId: string;
|
||||
readonly purpose: string;
|
||||
readonly ttlMs?: number;
|
||||
}
|
||||
|
||||
export interface PluginCredentialBroker {
|
||||
createLease(credential: SecretRef | CredentialRef, options: PluginCredentialLeaseOptions): Promise<SecretLeaseRef>;
|
||||
}
|
||||
|
||||
export interface PluginNetworkRequest {
|
||||
readonly url: string;
|
||||
readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
|
||||
readonly headers?: Readonly<Record<string, string>>;
|
||||
readonly body?: Readonly<{ encoding: "utf8" | "base64"; data: string }>;
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PluginNetworkResponse {
|
||||
readonly url: string;
|
||||
readonly status: number;
|
||||
readonly headers: Readonly<Record<string, string>>;
|
||||
readonly body: Readonly<{ encoding: "base64"; data: string }>;
|
||||
}
|
||||
|
||||
export interface PluginNetworkClient {
|
||||
request(request: PluginNetworkRequest): Promise<PluginNetworkResponse>;
|
||||
}
|
||||
|
||||
export interface PluginFilesystemEntry {
|
||||
readonly name: string;
|
||||
readonly kind: "file" | "directory" | "other";
|
||||
}
|
||||
|
||||
export interface PluginFilesystemStat {
|
||||
readonly kind: "file" | "directory" | "other";
|
||||
readonly size: number;
|
||||
readonly modifiedAt: number;
|
||||
}
|
||||
|
||||
export interface PluginFilesystemClient {
|
||||
readFile(path: string, options?: Readonly<{ encoding?: "utf8" | "base64"; maxBytes?: number }>): Promise<string>;
|
||||
writeFile(path: string, data: string, options: Readonly<{
|
||||
encoding?: "utf8" | "base64";
|
||||
overwrite: true;
|
||||
}>): Promise<void>;
|
||||
stat(path: string): Promise<PluginFilesystemStat>;
|
||||
readDirectory(path: string): Promise<readonly PluginFilesystemEntry[]>;
|
||||
}
|
||||
|
||||
export interface PluginCompanionRequestOptions {
|
||||
readonly timeoutMs?: number;
|
||||
/**
|
||||
* Operation-bound one-use leases consumed by the host immediately before
|
||||
* dispatching this request to the isolated companion. When present, the
|
||||
* companion receives `{ payload, credentials }` instead of the raw params.
|
||||
*/
|
||||
readonly credentialLeases?: Readonly<Record<string, SecretLeaseRef>>;
|
||||
readonly operationId?: string;
|
||||
}
|
||||
|
||||
export interface PluginCompanionHandle extends Disposable {
|
||||
readonly id: string;
|
||||
request<T extends JsonValue = JsonValue>(
|
||||
method: string,
|
||||
params?: JsonValue,
|
||||
options?: PluginCompanionRequestOptions,
|
||||
): Promise<T>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginCompanionService {
|
||||
start(companionId: string): Promise<PluginCompanionHandle>;
|
||||
}
|
||||
|
||||
export interface PluginReadableByteStream extends Disposable {
|
||||
readonly id: string;
|
||||
/**
|
||||
* Returns the next owned byte chunk or null after a normal end. Calling read
|
||||
* again releases receive credit for the previous chunk, so consumers should
|
||||
* finish processing one chunk before requesting the next.
|
||||
*/
|
||||
read(): Promise<Uint8Array | null>;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export interface PluginWritableByteStream extends Disposable {
|
||||
readonly id: string;
|
||||
write(data: Uint8Array | ArrayBuffer): Promise<void>;
|
||||
end(): Promise<void>;
|
||||
fail(error: Readonly<{ message: string }>): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export interface PluginStreams {
|
||||
acceptReadable(streamId: string): Promise<PluginReadableByteStream>;
|
||||
openWritable(streamId: string, options?: Readonly<{ windowBytes?: number }>): Promise<PluginWritableByteStream>;
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
readonly pluginId: PluginId;
|
||||
readonly netcattyVersion: SemanticVersion;
|
||||
readonly apiVersion: SemanticVersion;
|
||||
readonly enabledFeatures: ReadonlySet<FeatureId>;
|
||||
readonly subscriptions: DisposableStore;
|
||||
readonly storage: PluginKeyValueStore;
|
||||
readonly settings: PluginSettings;
|
||||
readonly commands: PluginCommands;
|
||||
readonly contextKeys: PluginContextKeys;
|
||||
readonly views: PluginViews;
|
||||
readonly providers: PluginProviders;
|
||||
readonly terminals: PluginTerminalSessions;
|
||||
readonly environment: PluginEnvironment;
|
||||
readonly secrets: PluginSecretStore;
|
||||
readonly credentials: PluginCredentialBroker;
|
||||
readonly network: PluginNetworkClient;
|
||||
readonly filesystem: PluginFilesystemClient;
|
||||
readonly companions: PluginCompanionService;
|
||||
readonly streams: PluginStreams;
|
||||
readonly logger: PluginLogger;
|
||||
}
|
||||
|
||||
export interface NetcattyPlugin {
|
||||
activate(context: PluginContext): void | Disposable | Promise<void | Disposable>;
|
||||
deactivate?(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export type PluginErrorCode = PluginErrorName;
|
||||
|
||||
export const PLUGIN_ERROR_WIRE_CODES = {
|
||||
cancelled: -32001,
|
||||
unknown: -32002,
|
||||
invalid_argument: -32003,
|
||||
deadline_exceeded: -32004,
|
||||
not_found: -32005,
|
||||
already_exists: -32006,
|
||||
permission_denied: -32007,
|
||||
resource_exhausted: -32008,
|
||||
failed_precondition: -32009,
|
||||
aborted: -32010,
|
||||
out_of_range: -32011,
|
||||
unsupported: -32012,
|
||||
internal: -32013,
|
||||
unavailable: -32014,
|
||||
data_loss: -32015,
|
||||
unauthenticated: -32016,
|
||||
} as const satisfies Readonly<Record<PluginErrorCode, PluginWireErrorCode>>;
|
||||
|
||||
export class PluginError extends Error {
|
||||
readonly code: PluginErrorCode;
|
||||
readonly details?: JsonValue;
|
||||
|
||||
constructor(code: PluginErrorCode, message: string, details?: JsonValue) {
|
||||
super(message);
|
||||
this.name = "PluginError";
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function pluginErrorToRpcError(error: PluginError): RpcErrorObject {
|
||||
const data: PluginErrorData = error.details === undefined
|
||||
? { pluginCode: error.code }
|
||||
: { pluginCode: error.code, details: error.details };
|
||||
return {
|
||||
code: PLUGIN_ERROR_WIRE_CODES[error.code],
|
||||
message: error.message,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export class CancellationError extends PluginError {
|
||||
constructor(message = "The operation was cancelled") {
|
||||
super("cancelled", message);
|
||||
this.name = "CancellationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DisposableStore implements Disposable {
|
||||
readonly #items = new Set<Disposable>();
|
||||
#isDisposed = false;
|
||||
|
||||
get isDisposed(): boolean {
|
||||
return this.#isDisposed;
|
||||
}
|
||||
|
||||
add<T extends Disposable>(disposable: T): T {
|
||||
if (this.#isDisposed) {
|
||||
disposable.dispose();
|
||||
throw new PluginError("unavailable", "Cannot add to a disposed DisposableStore");
|
||||
}
|
||||
this.#items.add(disposable);
|
||||
return disposable;
|
||||
}
|
||||
|
||||
delete(disposable: Disposable): boolean {
|
||||
return this.#items.delete(disposable);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
const items = [...this.#items];
|
||||
this.#items.clear();
|
||||
const errors: unknown[] = [];
|
||||
for (const item of items) {
|
||||
try {
|
||||
item.dispose();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors, "One or more plugin disposables failed");
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.#isDisposed) return;
|
||||
this.#isDisposed = true;
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class MutableCancellationToken implements CancellationToken {
|
||||
readonly #listeners = new Set<CancellationListener>();
|
||||
#isCancellationRequested = false;
|
||||
|
||||
get isCancellationRequested(): boolean {
|
||||
return this.#isCancellationRequested;
|
||||
}
|
||||
|
||||
onCancellationRequested(listener: CancellationListener): Disposable {
|
||||
if (this.#isCancellationRequested) {
|
||||
queueMicrotask(listener);
|
||||
return { dispose() {} };
|
||||
}
|
||||
this.#listeners.add(listener);
|
||||
return {
|
||||
dispose: () => {
|
||||
this.#listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.#isCancellationRequested) return;
|
||||
this.#isCancellationRequested = true;
|
||||
const listeners = [...this.#listeners];
|
||||
this.#listeners.clear();
|
||||
const errors: unknown[] = [];
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors, "One or more cancellation listeners failed");
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.#listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class CancellationTokenSource implements Disposable {
|
||||
readonly #token = new MutableCancellationToken();
|
||||
#isDisposed = false;
|
||||
|
||||
get token(): CancellationToken {
|
||||
return this.#token;
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (!this.#isDisposed) this.#token.cancel();
|
||||
}
|
||||
|
||||
dispose(cancel = false): void {
|
||||
if (this.#isDisposed) return;
|
||||
try {
|
||||
if (cancel) this.#token.cancel();
|
||||
} finally {
|
||||
this.#token.dispose();
|
||||
this.#isDisposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function definePlugin<T extends NetcattyPlugin>(plugin: T): T {
|
||||
return plugin;
|
||||
}
|
||||
|
||||
export function throwIfCancellationRequested(token: CancellationToken): void {
|
||||
if (token.isCancellationRequested) throw new CancellationError();
|
||||
}
|
||||
16
packages/plugin-sdk/tsconfig.build.json
Normal file
16
packages/plugin-sdk/tsconfig.build.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user