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
378 lines
15 KiB
JavaScript
378 lines
15 KiB
JavaScript
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const schemaPath = path.join(
|
|
rootDir,
|
|
"packages/plugin-contract/schema/plugin-contract.schema.json",
|
|
);
|
|
const generatedTypesPath = path.join(
|
|
rootDir,
|
|
"packages/plugin-contract/src/generated/plugin-contract.ts",
|
|
);
|
|
const generatedLimitsPath = path.join(
|
|
rootDir,
|
|
"packages/plugin-contract/src/generated/plugin-contract-limits.ts",
|
|
);
|
|
const electronBundlePath = path.join(
|
|
rootDir,
|
|
"electron/plugins/generated/plugin-contract.schema.json",
|
|
);
|
|
const checkOnly = process.argv.includes("--check");
|
|
const typescriptTypeOverrides = new Map([
|
|
[
|
|
"ActivationEvent",
|
|
'"onStartupFinished" | `onCommand:${ContributionId}` | `onView:${ContributionId}` | `onProvider:${ContributionId}`',
|
|
],
|
|
[
|
|
"PluginHostProtocol",
|
|
"`plugin:${ContributionId}`",
|
|
],
|
|
[
|
|
"ImporterGroupDraft",
|
|
"string | { path: string; label?: string } | { path?: string; label: string }",
|
|
],
|
|
[
|
|
"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;
|
|
}))`,
|
|
],
|
|
[
|
|
"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;
|
|
}))`,
|
|
],
|
|
]);
|
|
|
|
const schemaText = await readFile(schemaPath, "utf8");
|
|
const schema = JSON.parse(schemaText);
|
|
|
|
if (schema.$schema !== "https://json-schema.org/draft/2020-12/schema") {
|
|
throw new Error("Plugin contract must use JSON Schema 2020-12");
|
|
}
|
|
if (!schema.$id?.includes("/0.1.0-internal/")) {
|
|
throw new Error("Plugin contract $id must include the internal API version");
|
|
}
|
|
if (!schema.$defs || typeof schema.$defs !== "object") {
|
|
throw new Error("Plugin contract must define $defs");
|
|
}
|
|
const jsonValueLimits = schema.$defs.JsonValueLimits?.const;
|
|
if (!Number.isSafeInteger(jsonValueLimits?.maxDepth) || jsonValueLimits.maxDepth < 1) {
|
|
throw new Error("JsonValueLimits.maxDepth must be a positive safe integer");
|
|
}
|
|
if (!Number.isSafeInteger(jsonValueLimits?.maxNodes) || jsonValueLimits.maxNodes < 1) {
|
|
throw new Error("JsonValueLimits.maxNodes must be a positive safe integer");
|
|
}
|
|
const wireIntegerLimits = schema.$defs.WireIntegerLimits?.const;
|
|
if (wireIntegerLimits?.maxSafeInteger !== Number.MAX_SAFE_INTEGER) {
|
|
throw new Error("WireIntegerLimits.maxSafeInteger must equal Number.MAX_SAFE_INTEGER");
|
|
}
|
|
const rpcLimits = schema.$defs.RpcLimits?.const;
|
|
if (!Number.isSafeInteger(rpcLimits?.maxJsonBytes) || rpcLimits.maxJsonBytes < 1) {
|
|
throw new Error("RpcLimits.maxJsonBytes must be a positive safe integer");
|
|
}
|
|
for (const [definitionName, minimum] of [
|
|
["SafeUnsignedInteger", 0],
|
|
["SafePositiveInteger", 1],
|
|
]) {
|
|
const definition = schema.$defs[definitionName];
|
|
if (definition?.type !== "integer"
|
|
|| definition.minimum !== minimum
|
|
|| definition.maximum !== wireIntegerLimits.maxSafeInteger) {
|
|
throw new Error(
|
|
`${definitionName} must be bounded from ${minimum} through WireIntegerLimits.maxSafeInteger`,
|
|
);
|
|
}
|
|
}
|
|
const rpcErrorCodes = [
|
|
...(schema.$defs.JsonRpcStandardErrorCode?.enum ?? []),
|
|
...(schema.$defs.PluginWireErrorCode?.enum ?? []),
|
|
];
|
|
if (rpcErrorCodes.length === 0
|
|
|| rpcErrorCodes.some((code) => !Number.isSafeInteger(code))
|
|
|| new Set(rpcErrorCodes).size !== rpcErrorCodes.length) {
|
|
throw new Error("RPC error code definitions must contain unique safe integers");
|
|
}
|
|
const streamLimits = schema.$defs.StreamLimits?.const;
|
|
if (!Number.isSafeInteger(streamLimits?.maxFrameJsonBytes)
|
|
|| streamLimits.maxFrameJsonBytes < streamLimits?.maxChunkBytes) {
|
|
throw new Error("StreamLimits.maxFrameJsonBytes must cover one maximum stream chunk");
|
|
}
|
|
const terminalInterceptorLimits = schema.$defs.TerminalInterceptorLimits?.const;
|
|
if (!Number.isSafeInteger(terminalInterceptorLimits?.maxChunkBytes)
|
|
|| terminalInterceptorLimits.maxChunkBytes < 1
|
|
|| !Number.isSafeInteger(terminalInterceptorLimits?.maxWindowBytes)
|
|
|| terminalInterceptorLimits.maxWindowBytes < terminalInterceptorLimits.maxChunkBytes) {
|
|
throw new Error("TerminalInterceptorLimits must define bounded chunk and window sizes");
|
|
}
|
|
const importerLimits = schema.$defs.ImporterLimits?.const;
|
|
if (!Number.isSafeInteger(importerLimits?.maxInputBytes)
|
|
|| !Number.isSafeInteger(importerLimits?.maxOutputBytes)
|
|
|| !Number.isSafeInteger(importerLimits?.maxRecordBytes)
|
|
|| !Number.isSafeInteger(importerLimits?.maxRecords)
|
|
|| importerLimits.maxInputBytes < 1
|
|
|| importerLimits.maxOutputBytes < 1
|
|
|| importerLimits.maxRecordBytes < 1
|
|
|| importerLimits.maxRecordBytes > importerLimits.maxOutputBytes
|
|
|| importerLimits.maxRecords < 1) {
|
|
throw new Error("ImporterLimits must define positive bounded input, output, record, and count limits");
|
|
}
|
|
const syncLimits = schema.$defs.SyncLimits?.const;
|
|
if (!Number.isSafeInteger(syncLimits?.maxObjectBytes)
|
|
|| !Number.isSafeInteger(syncLimits?.maxObjectKeyLength)
|
|
|| !Number.isSafeInteger(syncLimits?.maxRevisionLength)
|
|
|| !Number.isSafeInteger(syncLimits?.inlineObjectBytes)
|
|
|| syncLimits.maxObjectBytes < 1
|
|
|| syncLimits.maxObjectKeyLength < 1
|
|
|| syncLimits.maxRevisionLength < 1
|
|
|| syncLimits.inlineObjectBytes < 1
|
|
|| syncLimits.inlineObjectBytes > syncLimits.maxObjectBytes) {
|
|
throw new Error("SyncLimits must define positive bounded object, key, revision, and inline size limits");
|
|
}
|
|
for (const [name, minimum, maximum] of [
|
|
["TerminalInterceptorChunkByteLength", 0, terminalInterceptorLimits.maxChunkBytes],
|
|
["TerminalInterceptorWindowBytes", 1, terminalInterceptorLimits.maxWindowBytes],
|
|
["TerminalInterceptorCreditBytes", 0, terminalInterceptorLimits.maxWindowBytes],
|
|
]) {
|
|
const definition = schema.$defs[name];
|
|
if (definition?.type !== "integer"
|
|
|| definition.minimum !== minimum
|
|
|| definition.maximum !== maximum) {
|
|
throw new Error(`${name} must match the canonical TerminalInterceptorLimits range`);
|
|
}
|
|
}
|
|
const streamIdDefinition = schema.$defs.StreamId;
|
|
if (!Number.isSafeInteger(streamLimits?.maxStreamIdLength)
|
|
|| streamLimits.maxStreamIdLength < 1
|
|
|| streamIdDefinition?.type !== "string"
|
|
|| streamIdDefinition.minLength !== 1
|
|
|| streamIdDefinition.maxLength !== streamLimits.maxStreamIdLength) {
|
|
throw new Error("StreamId must match the canonical StreamLimits.maxStreamIdLength");
|
|
}
|
|
const streamFrameBranches = schema.$defs.StreamFrame?.oneOf;
|
|
if (!Array.isArray(streamFrameBranches)
|
|
|| streamFrameBranches.length === 0
|
|
|| streamFrameBranches.some(
|
|
(branch) => branch?.properties?.streamId?.$ref !== "#/$defs/StreamId",
|
|
)) {
|
|
throw new Error("Every StreamFrame branch must use the canonical StreamId definition");
|
|
}
|
|
for (const [name, minimum, maximum] of [
|
|
["StreamChunkByteLength", 0, streamLimits?.maxChunkBytes],
|
|
["StreamWindowBytes", streamLimits?.minWindowBytes, streamLimits?.maxWindowBytes],
|
|
["StreamCreditBytes", 1, streamLimits?.maxCreditBytes],
|
|
]) {
|
|
if (!Number.isSafeInteger(minimum)
|
|
|| !Number.isSafeInteger(maximum)
|
|
|| minimum < 0
|
|
|| maximum < minimum) {
|
|
throw new Error(`StreamLimits contains an invalid range for ${name}`);
|
|
}
|
|
const definition = schema.$defs[name];
|
|
if (definition?.type !== "integer"
|
|
|| definition.minimum !== minimum
|
|
|| definition.maximum !== maximum) {
|
|
throw new Error(`${name} must match the canonical StreamLimits range`);
|
|
}
|
|
}
|
|
|
|
function quoteProperty(name) {
|
|
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
}
|
|
|
|
function literal(value) {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function referenceName(reference) {
|
|
const prefix = "#/$defs/";
|
|
if (typeof reference !== "string" || !reference.startsWith(prefix)) {
|
|
throw new Error(`Unsupported schema reference: ${String(reference)}`);
|
|
}
|
|
return decodeURIComponent(reference.slice(prefix.length));
|
|
}
|
|
|
|
function schemaTypeToTs(node, level = 0) {
|
|
if (node === true) return "unknown";
|
|
if (node === false) return "never";
|
|
if (!node || typeof node !== "object") return "unknown";
|
|
if ("$ref" in node) return referenceName(node.$ref);
|
|
if ("const" in node) return literal(node.const);
|
|
if (Array.isArray(node.enum)) return node.enum.map(literal).join(" | ") || "never";
|
|
|
|
for (const keyword of ["oneOf", "anyOf"]) {
|
|
if (Array.isArray(node[keyword])) {
|
|
return node[keyword].map((entry) => `(${schemaTypeToTs(entry, level)})`).join(" | ");
|
|
}
|
|
}
|
|
if (Array.isArray(node.allOf)) {
|
|
return node.allOf.map((entry) => `(${schemaTypeToTs(entry, level)})`).join(" & ");
|
|
}
|
|
if (Array.isArray(node.type)) {
|
|
return node.type.map((entry) => schemaTypeToTs({ ...node, type: entry }, level)).join(" | ");
|
|
}
|
|
|
|
if (node.type === "null") return "null";
|
|
if (node.type === "string") return "string";
|
|
if (node.type === "number" || node.type === "integer") return "number";
|
|
if (node.type === "boolean") return "boolean";
|
|
if (node.type === "array") {
|
|
return `Array<${schemaTypeToTs(node.items ?? true, level)}>`;
|
|
}
|
|
if (node.type === "object" || node.properties || node.additionalProperties) {
|
|
const properties = node.properties ?? {};
|
|
const entries = Object.entries(properties);
|
|
const required = new Set(node.required ?? []);
|
|
const additional = node.additionalProperties;
|
|
|
|
if (entries.length === 0) {
|
|
if (additional && typeof additional === "object") {
|
|
return `{ [key: string]: ${schemaTypeToTs(additional, level)} }`;
|
|
}
|
|
return additional === false ? "Record<string, never>" : "Record<string, unknown>";
|
|
}
|
|
|
|
const indent = " ".repeat(level + 1);
|
|
const closingIndent = " ".repeat(level);
|
|
const body = entries.map(([name, propertySchema]) => {
|
|
const optional = required.has(name) ? "" : "?";
|
|
return `${indent}${quoteProperty(name)}${optional}: ${schemaTypeToTs(propertySchema, level + 1)};`;
|
|
});
|
|
let objectType = `{\n${body.join("\n")}\n${closingIndent}}`;
|
|
if (additional && typeof additional === "object") {
|
|
objectType = `(${objectType} & Record<string, ${schemaTypeToTs(additional, level)}>)`;
|
|
} else if (additional === true) {
|
|
objectType = `(${objectType} & Record<string, unknown>)`;
|
|
}
|
|
return objectType;
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
const definitionNames = Object.keys(schema.$defs).sort((left, right) =>
|
|
left.localeCompare(right, "en"),
|
|
);
|
|
const generatedTypes = [
|
|
"// 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.",
|
|
"",
|
|
...definitionNames.flatMap((name) => [
|
|
`export type ${name} = ${typescriptTypeOverrides.get(name) ?? schemaTypeToTs(schema.$defs[name])};`,
|
|
"",
|
|
]),
|
|
].join("\n");
|
|
const generatedLimits = [
|
|
"// 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 = ${jsonValueLimits.maxDepth} as const;`,
|
|
`export const PLUGIN_JSON_MAX_NODES = ${jsonValueLimits.maxNodes} as const;`,
|
|
`export const PLUGIN_WIRE_MAX_SAFE_INTEGER = ${wireIntegerLimits.maxSafeInteger} as const;`,
|
|
`export const PLUGIN_RPC_MAX_JSON_BYTES = ${rpcLimits.maxJsonBytes} as const;`,
|
|
`export const PLUGIN_RPC_ERROR_CODES = ${JSON.stringify(rpcErrorCodes)} as const;`,
|
|
`export const PLUGIN_STREAM_MAX_ID_LENGTH = ${streamLimits.maxStreamIdLength} as const;`,
|
|
`export const PLUGIN_STREAM_MAX_CHUNK_BYTES = ${streamLimits.maxChunkBytes} as const;`,
|
|
`export const PLUGIN_STREAM_MAX_FRAME_JSON_BYTES = ${streamLimits.maxFrameJsonBytes} as const;`,
|
|
`export const PLUGIN_STREAM_MIN_WINDOW_BYTES = ${streamLimits.minWindowBytes} as const;`,
|
|
`export const PLUGIN_STREAM_MAX_WINDOW_BYTES = ${streamLimits.maxWindowBytes} as const;`,
|
|
`export const PLUGIN_STREAM_MAX_CREDIT_BYTES = ${streamLimits.maxCreditBytes} as const;`,
|
|
`export const PLUGIN_TERMINAL_INTERCEPTOR_MAX_CHUNK_BYTES = ${terminalInterceptorLimits.maxChunkBytes} as const;`,
|
|
`export const PLUGIN_TERMINAL_INTERCEPTOR_MAX_WINDOW_BYTES = ${terminalInterceptorLimits.maxWindowBytes} as const;`,
|
|
`export const PLUGIN_IMPORTER_MAX_INPUT_BYTES = ${importerLimits.maxInputBytes} as const;`,
|
|
`export const PLUGIN_IMPORTER_MAX_OUTPUT_BYTES = ${importerLimits.maxOutputBytes} as const;`,
|
|
`export const PLUGIN_IMPORTER_MAX_RECORD_BYTES = ${importerLimits.maxRecordBytes} as const;`,
|
|
`export const PLUGIN_IMPORTER_MAX_RECORDS = ${importerLimits.maxRecords} as const;`,
|
|
`export const PLUGIN_SYNC_MAX_OBJECT_BYTES = ${syncLimits.maxObjectBytes} as const;`,
|
|
`export const PLUGIN_SYNC_MAX_OBJECT_KEY_LENGTH = ${syncLimits.maxObjectKeyLength} as const;`,
|
|
`export const PLUGIN_SYNC_MAX_REVISION_LENGTH = ${syncLimits.maxRevisionLength} as const;`,
|
|
`export const PLUGIN_SYNC_INLINE_OBJECT_BYTES = ${syncLimits.inlineObjectBytes} as const;`,
|
|
"",
|
|
].join("\n");
|
|
const normalizedSchema = `${JSON.stringify(schema, null, 2)}\n`;
|
|
|
|
async function assertCurrent(filePath, expected, label) {
|
|
let actual;
|
|
try {
|
|
actual = await readFile(filePath, "utf8");
|
|
} catch {
|
|
throw new Error(`${label} is missing. Run npm run generate:plugin-contract.`);
|
|
}
|
|
if (actual !== expected) {
|
|
throw new Error(`${label} is out of date. Run npm run generate:plugin-contract.`);
|
|
}
|
|
}
|
|
|
|
if (checkOnly) {
|
|
await assertCurrent(generatedTypesPath, generatedTypes, "Generated plugin TypeScript contract");
|
|
await assertCurrent(generatedLimitsPath, generatedLimits, "Generated plugin JSON limits");
|
|
await assertCurrent(electronBundlePath, normalizedSchema, "Electron plugin schema bundle");
|
|
console.log("Plugin contract generated artifacts are current.");
|
|
} else {
|
|
await mkdir(path.dirname(generatedTypesPath), { recursive: true });
|
|
await mkdir(path.dirname(electronBundlePath), { recursive: true });
|
|
await Promise.all([
|
|
writeFile(generatedTypesPath, generatedTypes, "utf8"),
|
|
writeFile(generatedLimitsPath, generatedLimits, "utf8"),
|
|
writeFile(electronBundlePath, normalizedSchema, "utf8"),
|
|
]);
|
|
console.log("Generated plugin TypeScript contract and Electron schema bundle.");
|
|
}
|