🚀 Hello World
This commit is contained in:
commit
0172c07623
14 changed files with 11896 additions and 0 deletions
4
src/browser.ts
Normal file
4
src/browser.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Browser-compatible exports
|
||||
export * from "./schemas.js";
|
||||
export * from "./shared/config.js";
|
||||
export * from "./types.js";
|
||||
13
src/cli.ts
Normal file
13
src/cli.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// CLI-specific exports
|
||||
export * from "./cli/init.js";
|
||||
export * from "./cli/pack.js";
|
||||
|
||||
// Include all shared exports
|
||||
export * from "./schemas.js";
|
||||
export * from "./shared/config.js";
|
||||
export * from "./types.js";
|
||||
|
||||
// Include node exports since CLI needs them
|
||||
export * from "./node/files.js";
|
||||
export * from "./node/sign.js";
|
||||
export * from "./node/validate.js";
|
||||
314
src/cli/cli.ts
Normal file
314
src/cli/cli.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { execSync } from "child_process";
|
||||
import { Command } from "commander";
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { basename, dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
import { signDxtFile, unsignDxtFile, verifyDxtFile } from "../node/sign.js";
|
||||
import { validateManifest } from "../node/validate.js";
|
||||
import { initExtension } from "./init.js";
|
||||
import { packExtension } from "./pack.js";
|
||||
|
||||
// ES modules equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Get version from package.json
|
||||
const packageJsonPath = join(__dirname, "..", "..", "package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
||||
const version = packageJson.version;
|
||||
|
||||
/**
|
||||
* Create a self-signed certificate for signing DXT extensions
|
||||
*/
|
||||
function createSelfSignedCertificate(certPath: string, keyPath: string): void {
|
||||
const subject = "/CN=DXT Self-Signed Certificate/O=DXT Extensions/C=US";
|
||||
|
||||
try {
|
||||
// Generate a self-signed certificate valid for 10 years, no password
|
||||
execSync(
|
||||
`openssl req -x509 -newkey rsa:4096 -keyout "${keyPath}" -out "${certPath}" -days 3650 -nodes -subj "${subject}"`,
|
||||
{ stdio: "pipe" },
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create self-signed certificate: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the CLI program
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("dxt")
|
||||
.description("Tools for building Desktop Extensions")
|
||||
.version(version);
|
||||
|
||||
// Init command
|
||||
program
|
||||
.command("init [directory]")
|
||||
.description("Create a new DXT extension manifest")
|
||||
.option("-y, --yes", "Accept all defaults (non-interactive mode)")
|
||||
.action((directory?: string, options?: { yes?: boolean }) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const success = await initExtension(directory, options?.yes);
|
||||
process.exit(success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`ERROR: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Validate command
|
||||
program
|
||||
.command("validate <manifest>")
|
||||
.description("Validate a DXT manifest file")
|
||||
.action((manifestPath: string) => {
|
||||
const success = validateManifest(manifestPath);
|
||||
process.exit(success ? 0 : 1);
|
||||
});
|
||||
|
||||
// Pack command
|
||||
program
|
||||
.command("pack [directory] [output]")
|
||||
.description("Pack a directory into a DXT extension")
|
||||
.action((directory: string = process.cwd(), output?: string) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const success = await packExtension({
|
||||
extensionPath: directory,
|
||||
outputPath: output,
|
||||
});
|
||||
process.exit(success ? 0 : 1);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`ERROR: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Sign command
|
||||
program
|
||||
.command("sign <dxt-file>")
|
||||
.description("Sign a DXT extension file")
|
||||
.option(
|
||||
"-c, --cert <path>",
|
||||
"Path to certificate file (PEM format)",
|
||||
"cert.pem",
|
||||
)
|
||||
.option(
|
||||
"-k, --key <path>",
|
||||
"Path to private key file (PEM format)",
|
||||
"key.pem",
|
||||
)
|
||||
.option(
|
||||
"-i, --intermediate <paths...>",
|
||||
"Paths to intermediate certificate files",
|
||||
)
|
||||
.option("--self-signed", "Create a self-signed certificate if none exists")
|
||||
.action(
|
||||
(
|
||||
dxtFile: string,
|
||||
options: {
|
||||
cert: string;
|
||||
key: string;
|
||||
intermediate?: string[];
|
||||
selfSigned?: boolean;
|
||||
},
|
||||
) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const dxtPath = resolve(dxtFile);
|
||||
|
||||
if (!existsSync(dxtPath)) {
|
||||
console.error(`ERROR: DXT file not found: ${dxtFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let certPath = options.cert;
|
||||
let keyPath = options.key;
|
||||
|
||||
// Create self-signed certificate if requested
|
||||
if (options.selfSigned) {
|
||||
const dxtDir = resolve(__dirname, "..");
|
||||
certPath = join(dxtDir, "self-signed-cert.pem");
|
||||
keyPath = join(dxtDir, "self-signed-key.pem");
|
||||
|
||||
if (!existsSync(certPath) || !existsSync(keyPath)) {
|
||||
console.log("Creating self-signed certificate...");
|
||||
createSelfSignedCertificate(certPath, keyPath);
|
||||
console.log("Self-signed certificate created");
|
||||
} else {
|
||||
console.log("Using existing self-signed certificate");
|
||||
}
|
||||
} else {
|
||||
// Check for manual certificate paths
|
||||
if (!existsSync(certPath)) {
|
||||
console.error(`ERROR: Certificate file not found: ${certPath}`);
|
||||
console.log(
|
||||
"Tip: Use --self-signed to create a self-signed certificate",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(keyPath)) {
|
||||
console.error(`ERROR: Private key file not found: ${keyPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Signing ${basename(dxtPath)}...`);
|
||||
signDxtFile(dxtPath, certPath, keyPath, options.intermediate);
|
||||
console.log(`Successfully signed ${basename(dxtPath)}`);
|
||||
|
||||
// Display certificate info
|
||||
const signatureInfo = await verifyDxtFile(dxtPath);
|
||||
if (
|
||||
signatureInfo.status === "signed" ||
|
||||
signatureInfo.status === "self-signed"
|
||||
) {
|
||||
console.log(`Signed by: ${signatureInfo.publisher}`);
|
||||
console.log(`Issuer: ${signatureInfo.issuer}`);
|
||||
if (signatureInfo.status === "self-signed") {
|
||||
console.log(`Warning: Certificate is self-signed`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`ERROR: Signing failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
},
|
||||
);
|
||||
|
||||
// Verify command
|
||||
program
|
||||
.command("verify <dxt-file>")
|
||||
.description("Verify the signature of a DXT extension file")
|
||||
.action((dxtFile: string) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const dxtPath = resolve(dxtFile);
|
||||
|
||||
if (!existsSync(dxtPath)) {
|
||||
console.error(`ERROR: DXT file not found: ${dxtFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Verifying ${basename(dxtPath)}...`);
|
||||
const result = await verifyDxtFile(dxtPath);
|
||||
|
||||
if (result.status === "signed") {
|
||||
console.log(`Signature is valid`);
|
||||
console.log(`Signed by: ${result.publisher}`);
|
||||
console.log(`Issuer: ${result.issuer}`);
|
||||
console.log(
|
||||
`Valid from: ${new Date(result.valid_from!).toLocaleDateString()} to ${new Date(result.valid_to!).toLocaleDateString()}`,
|
||||
);
|
||||
console.log(`Fingerprint: ${result.fingerprint}`);
|
||||
} else if (result.status === "self-signed") {
|
||||
console.log(`Signature is valid (self-signed)`);
|
||||
console.log(`WARNING: This extension is self-signed`);
|
||||
console.log(`Signed by: ${result.publisher}`);
|
||||
console.log(
|
||||
`Valid from: ${new Date(result.valid_from!).toLocaleDateString()} to ${new Date(result.valid_to!).toLocaleDateString()}`,
|
||||
);
|
||||
console.log(`Fingerprint: ${result.fingerprint}`);
|
||||
} else {
|
||||
console.error(`ERROR: Extension is not signed`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`ERROR: Verification failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Info command
|
||||
program
|
||||
.command("info <dxt-file>")
|
||||
.description("Display information about a DXT extension file")
|
||||
.action((dxtFile: string) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const dxtPath = resolve(dxtFile);
|
||||
|
||||
if (!existsSync(dxtPath)) {
|
||||
console.error(`ERROR: DXT file not found: ${dxtFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stat = statSync(dxtPath);
|
||||
console.log(`File: ${basename(dxtPath)}`);
|
||||
console.log(`Size: ${(stat.size / 1024).toFixed(2)} KB`);
|
||||
|
||||
// Check if signed
|
||||
const signatureInfo = await verifyDxtFile(dxtPath);
|
||||
if (signatureInfo.status === "signed") {
|
||||
console.log(`\nSignature Information:`);
|
||||
console.log(` Subject: ${signatureInfo.publisher}`);
|
||||
console.log(` Issuer: ${signatureInfo.issuer}`);
|
||||
console.log(
|
||||
` Valid from: ${new Date(signatureInfo.valid_from!).toLocaleDateString()} to ${new Date(signatureInfo.valid_to!).toLocaleDateString()}`,
|
||||
);
|
||||
console.log(` Fingerprint: ${signatureInfo.fingerprint}`);
|
||||
console.log(` Status: Valid`);
|
||||
} else if (signatureInfo.status === "self-signed") {
|
||||
console.log(`\nSignature Information:`);
|
||||
console.log(` Subject: ${signatureInfo.publisher}`);
|
||||
console.log(` Issuer: ${signatureInfo.issuer} (self-signed)`);
|
||||
console.log(
|
||||
` Valid from: ${new Date(signatureInfo.valid_from!).toLocaleDateString()} to ${new Date(signatureInfo.valid_to!).toLocaleDateString()}`,
|
||||
);
|
||||
console.log(` Fingerprint: ${signatureInfo.fingerprint}`);
|
||||
console.log(` Status: Valid (self-signed)`);
|
||||
} else {
|
||||
console.log(`\nWARNING: Not signed`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`ERROR: Failed to read DXT info: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// Unsign command (for development/testing)
|
||||
program
|
||||
.command("unsign <dxt-file>")
|
||||
.description("Remove signature from a DXT extension file")
|
||||
.action((dxtFile: string) => {
|
||||
try {
|
||||
const dxtPath = resolve(dxtFile);
|
||||
|
||||
if (!existsSync(dxtPath)) {
|
||||
console.error(`ERROR: DXT file not found: ${dxtFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Removing signature from ${basename(dxtPath)}...`);
|
||||
unsignDxtFile(dxtPath);
|
||||
console.log(`Signature removed`);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`ERROR: Failed to remove signature: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Parse command line arguments
|
||||
program.parse();
|
||||
928
src/cli/init.ts
Normal file
928
src/cli/init.ts
Normal file
|
|
@ -0,0 +1,928 @@
|
|||
import { confirm, input, select } from "@inquirer/prompts";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { basename, join, resolve } from "path";
|
||||
|
||||
import type { DxtManifest } from "../types.js";
|
||||
|
||||
interface PackageJson {
|
||||
name?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
main?: string;
|
||||
author?: string | { name?: string; email?: string; url?: string };
|
||||
repository?: string | { type?: string; url?: string };
|
||||
license?: string;
|
||||
}
|
||||
|
||||
export function readPackageJson(dirPath: string): PackageJson {
|
||||
const packageJsonPath = join(dirPath, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
||||
} catch (e) {
|
||||
// Ignore package.json parsing errors
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function getDefaultAuthorName(packageData: PackageJson): string {
|
||||
if (typeof packageData.author === "string") {
|
||||
return packageData.author;
|
||||
}
|
||||
return packageData.author?.name || "";
|
||||
}
|
||||
|
||||
export function getDefaultAuthorEmail(packageData: PackageJson): string {
|
||||
if (typeof packageData.author === "object") {
|
||||
return packageData.author?.email || "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getDefaultAuthorUrl(packageData: PackageJson): string {
|
||||
if (typeof packageData.author === "object") {
|
||||
return packageData.author?.url || "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getDefaultRepositoryUrl(packageData: PackageJson): string {
|
||||
if (typeof packageData.repository === "string") {
|
||||
return packageData.repository;
|
||||
}
|
||||
return packageData.repository?.url || "";
|
||||
}
|
||||
|
||||
export function getDefaultBasicInfo(
|
||||
packageData: PackageJson,
|
||||
resolvedPath: string,
|
||||
) {
|
||||
const name = packageData.name || basename(resolvedPath);
|
||||
const authorName = getDefaultAuthorName(packageData) || "Unknown Author";
|
||||
const displayName = name;
|
||||
const version = packageData.version || "1.0.0";
|
||||
const description = packageData.description || "A DXT extension";
|
||||
|
||||
return { name, authorName, displayName, version, description };
|
||||
}
|
||||
|
||||
export function getDefaultAuthorInfo(packageData: PackageJson) {
|
||||
return {
|
||||
authorEmail: getDefaultAuthorEmail(packageData),
|
||||
authorUrl: getDefaultAuthorUrl(packageData),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDefaultServerConfig(packageData?: PackageJson) {
|
||||
const serverType = "node" as const;
|
||||
const entryPoint = getDefaultEntryPoint(serverType, packageData);
|
||||
const mcp_config = createMcpConfig(serverType, entryPoint);
|
||||
|
||||
return { serverType, entryPoint, mcp_config };
|
||||
}
|
||||
|
||||
export function getDefaultOptionalFields(packageData: PackageJson) {
|
||||
return {
|
||||
keywords: "",
|
||||
license: packageData.license || "MIT",
|
||||
repository: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMcpConfig(
|
||||
serverType: "node" | "python" | "binary",
|
||||
entryPoint: string,
|
||||
): {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
} {
|
||||
switch (serverType) {
|
||||
case "node":
|
||||
return {
|
||||
command: "node",
|
||||
args: ["${__dirname}/" + entryPoint],
|
||||
env: {},
|
||||
};
|
||||
case "python":
|
||||
return {
|
||||
command: "python",
|
||||
args: ["${__dirname}/" + entryPoint],
|
||||
env: {
|
||||
PYTHONPATH: "server/lib",
|
||||
},
|
||||
};
|
||||
case "binary":
|
||||
return {
|
||||
command: "${__dirname}/" + entryPoint,
|
||||
args: [],
|
||||
env: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultEntryPoint(
|
||||
serverType: "node" | "python" | "binary",
|
||||
packageData?: PackageJson,
|
||||
): string {
|
||||
switch (serverType) {
|
||||
case "node":
|
||||
return packageData?.main || "server/index.js";
|
||||
case "python":
|
||||
return "server/main.py";
|
||||
case "binary":
|
||||
return "server/my-server";
|
||||
}
|
||||
}
|
||||
|
||||
export async function promptBasicInfo(
|
||||
packageData: PackageJson,
|
||||
resolvedPath: string,
|
||||
) {
|
||||
const defaultName = packageData.name || basename(resolvedPath);
|
||||
|
||||
const name = await input({
|
||||
message: "Extension name:",
|
||||
default: defaultName,
|
||||
validate: (value) => value.trim().length > 0 || "Name is required",
|
||||
});
|
||||
|
||||
const authorName = await input({
|
||||
message: "Author name:",
|
||||
default: getDefaultAuthorName(packageData),
|
||||
validate: (value) => value.trim().length > 0 || "Author name is required",
|
||||
});
|
||||
|
||||
const displayName = await input({
|
||||
message: "Display name (optional):",
|
||||
default: name,
|
||||
});
|
||||
|
||||
const version = await input({
|
||||
message: "Version:",
|
||||
default: packageData.version || "1.0.0",
|
||||
validate: (value) => {
|
||||
if (!value.trim()) return "Version is required";
|
||||
if (!/^\d+\.\d+\.\d+/.test(value)) {
|
||||
return "Version must follow semantic versioning (e.g., 1.0.0)";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const description = await input({
|
||||
message: "Description:",
|
||||
default: packageData.description || "",
|
||||
validate: (value) => value.trim().length > 0 || "Description is required",
|
||||
});
|
||||
|
||||
return { name, authorName, displayName, version, description };
|
||||
}
|
||||
|
||||
export async function promptAuthorInfo(packageData: PackageJson) {
|
||||
const authorEmail = await input({
|
||||
message: "Author email (optional):",
|
||||
default: getDefaultAuthorEmail(packageData),
|
||||
});
|
||||
|
||||
const authorUrl = await input({
|
||||
message: "Author URL (optional):",
|
||||
default: getDefaultAuthorUrl(packageData),
|
||||
});
|
||||
|
||||
return { authorEmail, authorUrl };
|
||||
}
|
||||
|
||||
export async function promptServerConfig(packageData?: PackageJson) {
|
||||
const serverType = (await select({
|
||||
message: "Server type:",
|
||||
choices: [
|
||||
{ name: "Node.js", value: "node" },
|
||||
{ name: "Python", value: "python" },
|
||||
{ name: "Binary", value: "binary" },
|
||||
],
|
||||
default: "node",
|
||||
})) as "node" | "python" | "binary";
|
||||
|
||||
const entryPoint = await input({
|
||||
message: "Entry point:",
|
||||
default: getDefaultEntryPoint(serverType, packageData),
|
||||
});
|
||||
|
||||
const mcp_config = createMcpConfig(serverType, entryPoint);
|
||||
|
||||
return { serverType, entryPoint, mcp_config };
|
||||
}
|
||||
|
||||
export async function promptTools() {
|
||||
const addTools = await confirm({
|
||||
message:
|
||||
"Does your MCP Server provide tools you want to advertise (optional)?",
|
||||
default: true,
|
||||
});
|
||||
|
||||
const tools: Array<{ name: string; description?: string }> = [];
|
||||
let toolsGenerated = false;
|
||||
|
||||
if (addTools) {
|
||||
let addMore = true;
|
||||
while (addMore) {
|
||||
const toolName = await input({
|
||||
message: "Tool name:",
|
||||
validate: (value) => value.trim().length > 0 || "Tool name is required",
|
||||
});
|
||||
const toolDescription = await input({
|
||||
message: "Tool description (optional):",
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: toolName,
|
||||
...(toolDescription ? { description: toolDescription } : {}),
|
||||
});
|
||||
|
||||
addMore = await confirm({
|
||||
message: "Add another tool?",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Ask about generated tools
|
||||
toolsGenerated = await confirm({
|
||||
message: "Does your server generate additional tools at runtime?",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
|
||||
return { tools, toolsGenerated };
|
||||
}
|
||||
|
||||
export async function promptPrompts() {
|
||||
const addPrompts = await confirm({
|
||||
message:
|
||||
"Does your MCP Server provide prompts you want to advertise (optional)?",
|
||||
default: false,
|
||||
});
|
||||
|
||||
const prompts: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
arguments?: string[];
|
||||
text: string;
|
||||
}> = [];
|
||||
let promptsGenerated = false;
|
||||
|
||||
if (addPrompts) {
|
||||
let addMore = true;
|
||||
while (addMore) {
|
||||
const promptName = await input({
|
||||
message: "Prompt name:",
|
||||
validate: (value) =>
|
||||
value.trim().length > 0 || "Prompt name is required",
|
||||
});
|
||||
const promptDescription = await input({
|
||||
message: "Prompt description (optional):",
|
||||
});
|
||||
|
||||
// Ask about arguments
|
||||
const hasArguments = await confirm({
|
||||
message: "Does this prompt have arguments?",
|
||||
default: false,
|
||||
});
|
||||
|
||||
const argumentNames: string[] = [];
|
||||
if (hasArguments) {
|
||||
let addMoreArgs = true;
|
||||
while (addMoreArgs) {
|
||||
const argName = await input({
|
||||
message: "Argument name:",
|
||||
validate: (value) => {
|
||||
if (!value.trim()) return "Argument name is required";
|
||||
if (argumentNames.includes(value)) {
|
||||
return "Argument names must be unique";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
argumentNames.push(argName);
|
||||
|
||||
addMoreArgs = await confirm({
|
||||
message: "Add another argument?",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt for the text template
|
||||
const promptText = await input({
|
||||
message: hasArguments
|
||||
? `Prompt text (use \${arguments.name} for arguments: ${argumentNames.join(", ")}):`
|
||||
: "Prompt text:",
|
||||
validate: (value) =>
|
||||
value.trim().length > 0 || "Prompt text is required",
|
||||
});
|
||||
|
||||
prompts.push({
|
||||
name: promptName,
|
||||
...(promptDescription ? { description: promptDescription } : {}),
|
||||
...(argumentNames.length > 0 ? { arguments: argumentNames } : {}),
|
||||
text: promptText,
|
||||
});
|
||||
|
||||
addMore = await confirm({
|
||||
message: "Add another prompt?",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Ask about generated prompts
|
||||
promptsGenerated = await confirm({
|
||||
message: "Does your server generate additional prompts at runtime?",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
|
||||
return { prompts, promptsGenerated };
|
||||
}
|
||||
|
||||
export async function promptOptionalFields(packageData: PackageJson) {
|
||||
const keywords = await input({
|
||||
message: "Keywords (comma-separated, optional):",
|
||||
default: "",
|
||||
});
|
||||
|
||||
const license = await input({
|
||||
message: "License:",
|
||||
default: packageData.license || "MIT",
|
||||
});
|
||||
|
||||
const addRepository = await confirm({
|
||||
message: "Add repository information?",
|
||||
default: !!packageData.repository,
|
||||
});
|
||||
|
||||
let repository: { type: string; url: string } | undefined;
|
||||
if (addRepository) {
|
||||
const repoUrl = await input({
|
||||
message: "Repository URL:",
|
||||
default: getDefaultRepositoryUrl(packageData),
|
||||
});
|
||||
if (repoUrl) {
|
||||
repository = {
|
||||
type: "git",
|
||||
url: repoUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { keywords, license, repository };
|
||||
}
|
||||
|
||||
export async function promptLongDescription(description: string) {
|
||||
const hasLongDescription = await confirm({
|
||||
message: "Add a detailed long description?",
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (hasLongDescription) {
|
||||
const longDescription = await input({
|
||||
message: "Long description (supports basic markdown):",
|
||||
default: description,
|
||||
});
|
||||
return longDescription;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function promptUrls() {
|
||||
const homepage = await input({
|
||||
message: "Homepage URL (optional):",
|
||||
validate: (value) => {
|
||||
if (!value.trim()) return true;
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return "Must be a valid URL (e.g., https://example.com)";
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const documentation = await input({
|
||||
message: "Documentation URL (optional):",
|
||||
validate: (value) => {
|
||||
if (!value.trim()) return true;
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return "Must be a valid URL";
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const support = await input({
|
||||
message: "Support URL (optional):",
|
||||
validate: (value) => {
|
||||
if (!value.trim()) return true;
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return "Must be a valid URL";
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { homepage, documentation, support };
|
||||
}
|
||||
|
||||
export async function promptVisualAssets() {
|
||||
const icon = await input({
|
||||
message: "Icon file path (optional, relative to manifest):",
|
||||
validate: (value) => {
|
||||
if (!value.trim()) return true;
|
||||
if (value.includes("..")) return "Relative paths cannot include '..'";
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const addScreenshots = await confirm({
|
||||
message: "Add screenshots?",
|
||||
default: false,
|
||||
});
|
||||
|
||||
const screenshots: string[] = [];
|
||||
if (addScreenshots) {
|
||||
let addMore = true;
|
||||
while (addMore) {
|
||||
const screenshot = await input({
|
||||
message: "Screenshot file path (relative to manifest):",
|
||||
validate: (value) => {
|
||||
if (!value.trim()) return "Screenshot path is required";
|
||||
if (value.includes("..")) return "Relative paths cannot include '..'";
|
||||
return true;
|
||||
},
|
||||
});
|
||||
screenshots.push(screenshot);
|
||||
|
||||
addMore = await confirm({
|
||||
message: "Add another screenshot?",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { icon, screenshots };
|
||||
}
|
||||
|
||||
export async function promptCompatibility(
|
||||
serverType: "node" | "python" | "binary",
|
||||
) {
|
||||
const addCompatibility = await confirm({
|
||||
message: "Add compatibility constraints?",
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (!addCompatibility) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const addPlatforms = await confirm({
|
||||
message: "Specify supported platforms?",
|
||||
default: false,
|
||||
});
|
||||
|
||||
let platforms: ("darwin" | "win32" | "linux")[] | undefined;
|
||||
if (addPlatforms) {
|
||||
const selectedPlatforms: ("darwin" | "win32" | "linux")[] = [];
|
||||
|
||||
const supportsDarwin = await confirm({
|
||||
message: "Support macOS (darwin)?",
|
||||
default: true,
|
||||
});
|
||||
if (supportsDarwin) selectedPlatforms.push("darwin");
|
||||
|
||||
const supportsWin32 = await confirm({
|
||||
message: "Support Windows (win32)?",
|
||||
default: true,
|
||||
});
|
||||
if (supportsWin32) selectedPlatforms.push("win32");
|
||||
|
||||
const supportsLinux = await confirm({
|
||||
message: "Support Linux?",
|
||||
default: true,
|
||||
});
|
||||
if (supportsLinux) selectedPlatforms.push("linux");
|
||||
|
||||
platforms = selectedPlatforms.length > 0 ? selectedPlatforms : undefined;
|
||||
}
|
||||
|
||||
let runtimes: { python?: string; node?: string } | undefined;
|
||||
if (serverType !== "binary") {
|
||||
const addRuntimes = await confirm({
|
||||
message: "Specify runtime version constraints?",
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (addRuntimes) {
|
||||
if (serverType === "python") {
|
||||
const pythonVersion = await input({
|
||||
message: "Python version constraint (e.g., >=3.8,<4.0):",
|
||||
validate: (value) =>
|
||||
value.trim().length > 0 || "Python version constraint is required",
|
||||
});
|
||||
runtimes = { python: pythonVersion };
|
||||
} else if (serverType === "node") {
|
||||
const nodeVersion = await input({
|
||||
message: "Node.js version constraint (e.g., >=16.0.0):",
|
||||
validate: (value) =>
|
||||
value.trim().length > 0 || "Node.js version constraint is required",
|
||||
});
|
||||
runtimes = { node: nodeVersion };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...(platforms ? { platforms } : {}),
|
||||
...(runtimes ? { runtimes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function promptUserConfig() {
|
||||
const addUserConfig = await confirm({
|
||||
message: "Add user-configurable options?",
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (!addUserConfig) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const userConfig: Record<
|
||||
string,
|
||||
{
|
||||
type: "string" | "number" | "boolean" | "directory" | "file";
|
||||
title: string;
|
||||
description: string;
|
||||
required?: boolean;
|
||||
default?: string | number | boolean | string[];
|
||||
multiple?: boolean;
|
||||
sensitive?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
let addMore = true;
|
||||
while (addMore) {
|
||||
const optionKey = await input({
|
||||
message: "Configuration option key (unique identifier):",
|
||||
validate: (value) => {
|
||||
if (!value.trim()) return "Key is required";
|
||||
if (userConfig[value]) return "Key must be unique";
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const optionType = (await select({
|
||||
message: "Option type:",
|
||||
choices: [
|
||||
{ name: "String", value: "string" },
|
||||
{ name: "Number", value: "number" },
|
||||
{ name: "Boolean", value: "boolean" },
|
||||
{ name: "Directory", value: "directory" },
|
||||
{ name: "File", value: "file" },
|
||||
],
|
||||
})) as "string" | "number" | "boolean" | "directory" | "file";
|
||||
|
||||
const optionTitle = await input({
|
||||
message: "Option title (human-readable name):",
|
||||
validate: (value) => value.trim().length > 0 || "Title is required",
|
||||
});
|
||||
|
||||
const optionDescription = await input({
|
||||
message: "Option description:",
|
||||
validate: (value) => value.trim().length > 0 || "Description is required",
|
||||
});
|
||||
|
||||
const optionRequired = await confirm({
|
||||
message: "Is this option required?",
|
||||
default: false,
|
||||
});
|
||||
|
||||
const optionSensitive = await confirm({
|
||||
message: "Is this option sensitive (like a password)?",
|
||||
default: false,
|
||||
});
|
||||
|
||||
// Build the option object
|
||||
const option: {
|
||||
type: "string" | "number" | "boolean" | "directory" | "file";
|
||||
title: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
sensitive: boolean;
|
||||
default?: string | number | boolean | string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
} = {
|
||||
type: optionType,
|
||||
title: optionTitle,
|
||||
description: optionDescription,
|
||||
required: optionRequired,
|
||||
sensitive: optionSensitive,
|
||||
};
|
||||
|
||||
// Add default value if not required
|
||||
if (!optionRequired) {
|
||||
let defaultValue: string | number | boolean | string[] | undefined;
|
||||
if (optionType === "boolean") {
|
||||
defaultValue = await confirm({
|
||||
message: "Default value:",
|
||||
default: false,
|
||||
});
|
||||
} else if (optionType === "number") {
|
||||
const defaultStr = await input({
|
||||
message: "Default value (number):",
|
||||
validate: (value) => {
|
||||
if (!value.trim()) return true;
|
||||
return !isNaN(Number(value)) || "Must be a valid number";
|
||||
},
|
||||
});
|
||||
defaultValue = defaultStr ? Number(defaultStr) : undefined;
|
||||
} else {
|
||||
defaultValue = await input({
|
||||
message: "Default value (optional):",
|
||||
});
|
||||
}
|
||||
if (defaultValue !== undefined && defaultValue !== "") {
|
||||
option.default = defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Add constraints for number types
|
||||
if (optionType === "number") {
|
||||
const addConstraints = await confirm({
|
||||
message: "Add min/max constraints?",
|
||||
default: false,
|
||||
});
|
||||
if (addConstraints) {
|
||||
const min = await input({
|
||||
message: "Minimum value (optional):",
|
||||
validate: (value) => {
|
||||
if (!value.trim()) return true;
|
||||
return !isNaN(Number(value)) || "Must be a valid number";
|
||||
},
|
||||
});
|
||||
const max = await input({
|
||||
message: "Maximum value (optional):",
|
||||
validate: (value) => {
|
||||
if (!value.trim()) return true;
|
||||
return !isNaN(Number(value)) || "Must be a valid number";
|
||||
},
|
||||
});
|
||||
if (min) option.min = Number(min);
|
||||
if (max) option.max = Number(max);
|
||||
}
|
||||
}
|
||||
|
||||
userConfig[optionKey] = option;
|
||||
|
||||
addMore = await confirm({
|
||||
message: "Add another configuration option?",
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
|
||||
return userConfig;
|
||||
}
|
||||
|
||||
export function buildManifest(
|
||||
basicInfo: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
version: string;
|
||||
description: string;
|
||||
authorName: string;
|
||||
},
|
||||
longDescription: string | undefined,
|
||||
authorInfo: {
|
||||
authorEmail: string;
|
||||
authorUrl: string;
|
||||
},
|
||||
urls: {
|
||||
homepage: string;
|
||||
documentation: string;
|
||||
support: string;
|
||||
},
|
||||
visualAssets: {
|
||||
icon: string;
|
||||
screenshots: string[];
|
||||
},
|
||||
serverConfig: {
|
||||
serverType: "node" | "python" | "binary";
|
||||
entryPoint: string;
|
||||
mcp_config: {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
},
|
||||
tools: Array<{ name: string; description?: string }>,
|
||||
toolsGenerated: boolean,
|
||||
prompts: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
arguments?: string[];
|
||||
text: string;
|
||||
}>,
|
||||
promptsGenerated: boolean,
|
||||
compatibility:
|
||||
| {
|
||||
claude_desktop?: string;
|
||||
platforms?: ("darwin" | "win32" | "linux")[];
|
||||
runtimes?: { python?: string; node?: string };
|
||||
}
|
||||
| undefined,
|
||||
userConfig: Record<
|
||||
string,
|
||||
{
|
||||
type: "string" | "number" | "boolean" | "directory" | "file";
|
||||
title: string;
|
||||
description: string;
|
||||
required?: boolean;
|
||||
default?: string | number | boolean | string[];
|
||||
multiple?: boolean;
|
||||
sensitive?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
>,
|
||||
optionalFields: {
|
||||
keywords: string;
|
||||
license: string;
|
||||
repository?: { type: string; url: string };
|
||||
},
|
||||
): DxtManifest {
|
||||
const { name, displayName, version, description, authorName } = basicInfo;
|
||||
const { authorEmail, authorUrl } = authorInfo;
|
||||
const { serverType, entryPoint, mcp_config } = serverConfig;
|
||||
const { keywords, license, repository } = optionalFields;
|
||||
|
||||
return {
|
||||
dxt_version: "0.1",
|
||||
name,
|
||||
...(displayName && displayName !== name
|
||||
? { display_name: displayName }
|
||||
: {}),
|
||||
version,
|
||||
description,
|
||||
...(longDescription ? { long_description: longDescription } : {}),
|
||||
author: {
|
||||
name: authorName,
|
||||
...(authorEmail ? { email: authorEmail } : {}),
|
||||
...(authorUrl ? { url: authorUrl } : {}),
|
||||
},
|
||||
...(urls.homepage ? { homepage: urls.homepage } : {}),
|
||||
...(urls.documentation ? { documentation: urls.documentation } : {}),
|
||||
...(urls.support ? { support: urls.support } : {}),
|
||||
...(visualAssets.icon ? { icon: visualAssets.icon } : {}),
|
||||
...(visualAssets.screenshots.length > 0
|
||||
? { screenshots: visualAssets.screenshots }
|
||||
: {}),
|
||||
server: {
|
||||
type: serverType,
|
||||
entry_point: entryPoint,
|
||||
mcp_config,
|
||||
},
|
||||
...(tools.length > 0 ? { tools } : {}),
|
||||
...(toolsGenerated ? { tools_generated: true } : {}),
|
||||
...(prompts.length > 0 ? { prompts } : {}),
|
||||
...(promptsGenerated ? { prompts_generated: true } : {}),
|
||||
...(compatibility ? { compatibility } : {}),
|
||||
...(Object.keys(userConfig).length > 0 ? { user_config: userConfig } : {}),
|
||||
...(keywords
|
||||
? {
|
||||
keywords: keywords
|
||||
.split(",")
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k),
|
||||
}
|
||||
: {}),
|
||||
...(license ? { license } : {}),
|
||||
...(repository ? { repository } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function printNextSteps() {
|
||||
console.log("\nNext steps:");
|
||||
console.log(
|
||||
`1. Ensure all your production dependencies are in this directory`,
|
||||
);
|
||||
console.log(`2. Run 'dxt pack' to create your .dxt file`);
|
||||
}
|
||||
|
||||
export async function initExtension(
|
||||
targetPath: string = process.cwd(),
|
||||
nonInteractive = false,
|
||||
): Promise<boolean> {
|
||||
const resolvedPath = resolve(targetPath);
|
||||
const manifestPath = join(resolvedPath, "manifest.json");
|
||||
|
||||
if (existsSync(manifestPath)) {
|
||||
if (nonInteractive) {
|
||||
console.log(
|
||||
"manifest.json already exists. Use --force to overwrite in non-interactive mode.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const overwrite = await confirm({
|
||||
message: "manifest.json already exists. Overwrite?",
|
||||
default: false,
|
||||
});
|
||||
if (!overwrite) {
|
||||
console.log("Cancelled");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!nonInteractive) {
|
||||
console.log(
|
||||
"This utility will help you create a manifest.json file for your DXT extension.",
|
||||
);
|
||||
console.log("Press ^C at any time to quit.\n");
|
||||
} else {
|
||||
console.log("Creating manifest.json with default values...");
|
||||
}
|
||||
|
||||
try {
|
||||
const packageData = readPackageJson(resolvedPath);
|
||||
|
||||
// Prompt for all information or use defaults
|
||||
const basicInfo = nonInteractive
|
||||
? getDefaultBasicInfo(packageData, resolvedPath)
|
||||
: await promptBasicInfo(packageData, resolvedPath);
|
||||
const longDescription = nonInteractive
|
||||
? undefined
|
||||
: await promptLongDescription(basicInfo.description);
|
||||
const authorInfo = nonInteractive
|
||||
? getDefaultAuthorInfo(packageData)
|
||||
: await promptAuthorInfo(packageData);
|
||||
const urls = nonInteractive
|
||||
? { homepage: "", documentation: "", support: "" }
|
||||
: await promptUrls();
|
||||
const visualAssets = nonInteractive
|
||||
? { icon: "", screenshots: [] }
|
||||
: await promptVisualAssets();
|
||||
const serverConfig = nonInteractive
|
||||
? getDefaultServerConfig(packageData)
|
||||
: await promptServerConfig(packageData);
|
||||
const toolsData = nonInteractive
|
||||
? { tools: [], toolsGenerated: false }
|
||||
: await promptTools();
|
||||
const promptsData = nonInteractive
|
||||
? { prompts: [], promptsGenerated: false }
|
||||
: await promptPrompts();
|
||||
const compatibility = nonInteractive
|
||||
? undefined
|
||||
: await promptCompatibility(serverConfig.serverType);
|
||||
const userConfig = nonInteractive ? {} : await promptUserConfig();
|
||||
const optionalFields = nonInteractive
|
||||
? getDefaultOptionalFields(packageData)
|
||||
: await promptOptionalFields(packageData);
|
||||
|
||||
// Build manifest
|
||||
const manifest = buildManifest(
|
||||
basicInfo,
|
||||
longDescription,
|
||||
authorInfo,
|
||||
urls,
|
||||
visualAssets,
|
||||
serverConfig,
|
||||
toolsData.tools,
|
||||
toolsData.toolsGenerated,
|
||||
promptsData.prompts,
|
||||
promptsData.promptsGenerated,
|
||||
compatibility,
|
||||
userConfig,
|
||||
optionalFields,
|
||||
);
|
||||
|
||||
// Write manifest
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
||||
console.log(`\nCreated manifest.json at ${manifestPath}`);
|
||||
|
||||
printNextSteps();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("User force closed")) {
|
||||
console.log("\nCancelled");
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
222
src/cli/pack.ts
Normal file
222
src/cli/pack.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { confirm } from "@inquirer/prompts";
|
||||
import { createHash } from "crypto";
|
||||
import { zipSync } from "fflate";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { basename, join, relative, resolve, sep } from "path";
|
||||
|
||||
import { getAllFilesWithCount, readDxtIgnorePatterns } from "../node/files.js";
|
||||
import { validateManifest } from "../node/validate.js";
|
||||
import { DxtManifestSchema } from "../schemas.js";
|
||||
import { getLogger } from "../shared/log.js";
|
||||
import { initExtension } from "./init.js";
|
||||
|
||||
interface PackOptions {
|
||||
extensionPath: string;
|
||||
outputPath?: string;
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes}B`;
|
||||
} else if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)}kB`;
|
||||
} else {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeNameForFilename(name: string): string {
|
||||
// Replace spaces with hyphens
|
||||
// Remove or replace characters that are problematic in filenames
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
||||
.replace(/[^a-z0-9-_.]/g, "") // Keep only alphanumeric, hyphens, underscores, and dots
|
||||
.replace(/-+/g, "-") // Replace multiple hyphens with single hyphen
|
||||
.replace(/^-+|-+$/g, "") // Remove leading/trailing hyphens
|
||||
.substring(0, 100); // Limit length to 100 characters
|
||||
}
|
||||
|
||||
export async function packExtension({
|
||||
extensionPath,
|
||||
outputPath,
|
||||
silent,
|
||||
}: PackOptions): Promise<boolean> {
|
||||
const resolvedPath = resolve(extensionPath);
|
||||
const logger = getLogger({ silent });
|
||||
|
||||
// Check if directory exists
|
||||
if (!existsSync(resolvedPath) || !statSync(resolvedPath).isDirectory()) {
|
||||
logger.error(`ERROR: Directory not found: ${extensionPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if manifest exists
|
||||
const manifestPath = join(resolvedPath, "manifest.json");
|
||||
if (!existsSync(manifestPath)) {
|
||||
logger.log(`No manifest.json found in ${extensionPath}`);
|
||||
const shouldInit = await confirm({
|
||||
message: "Would you like to create a manifest.json file?",
|
||||
default: true,
|
||||
});
|
||||
|
||||
if (shouldInit) {
|
||||
const success = await initExtension(extensionPath);
|
||||
if (!success) {
|
||||
logger.error("ERROR: Failed to create manifest");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
logger.error("ERROR: Cannot pack extension without manifest.json");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate manifest first
|
||||
logger.log("Validating manifest...");
|
||||
if (!validateManifest(manifestPath)) {
|
||||
logger.error("ERROR: Cannot pack extension with invalid manifest");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read and parse manifest
|
||||
let manifest;
|
||||
try {
|
||||
const manifestContent = readFileSync(manifestPath, "utf-8");
|
||||
const manifestData = JSON.parse(manifestContent);
|
||||
manifest = DxtManifestSchema.parse(manifestData);
|
||||
} catch (error) {
|
||||
logger.error("ERROR: Failed to parse manifest.json");
|
||||
if (error instanceof Error) {
|
||||
logger.error(` ${error.message}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine output path
|
||||
const extensionName = basename(resolvedPath);
|
||||
const finalOutputPath = outputPath
|
||||
? resolve(outputPath)
|
||||
: resolve(`${extensionName}.dxt`);
|
||||
|
||||
// Ensure output directory exists
|
||||
const outputDir = join(finalOutputPath, "..");
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Read .dxtignore patterns if present
|
||||
const dxtIgnorePatterns = readDxtIgnorePatterns(resolvedPath);
|
||||
|
||||
// Get all files in the extension directory
|
||||
const { files, ignoredCount } = getAllFilesWithCount(
|
||||
resolvedPath,
|
||||
resolvedPath,
|
||||
{},
|
||||
dxtIgnorePatterns,
|
||||
);
|
||||
|
||||
// Print package header
|
||||
logger.log(`\n📦 ${manifest.name}@${manifest.version}`);
|
||||
|
||||
// Print file list
|
||||
logger.log("Archive Contents");
|
||||
const fileEntries = Object.entries(files);
|
||||
let totalUnpackedSize = 0;
|
||||
|
||||
// Sort files for consistent output
|
||||
fileEntries.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
// Group files by directory for deep nesting
|
||||
const directoryGroups = new Map<
|
||||
string,
|
||||
{ files: string[]; totalSize: number }
|
||||
>();
|
||||
const shallowFiles: Array<{ path: string; size: number }> = [];
|
||||
|
||||
for (const [filePath, content] of fileEntries) {
|
||||
const relPath = relative(resolvedPath, filePath);
|
||||
const size =
|
||||
typeof content === "string"
|
||||
? Buffer.byteLength(content, "utf8")
|
||||
: content.length;
|
||||
totalUnpackedSize += size;
|
||||
|
||||
// Check if file is deeply nested (3+ levels)
|
||||
const parts = relPath.split(sep);
|
||||
if (parts.length > 3) {
|
||||
// Group by the first 3 directory levels
|
||||
const groupKey = parts.slice(0, 3).join("/");
|
||||
if (!directoryGroups.has(groupKey)) {
|
||||
directoryGroups.set(groupKey, { files: [], totalSize: 0 });
|
||||
}
|
||||
const group = directoryGroups.get(groupKey)!;
|
||||
group.files.push(relPath);
|
||||
group.totalSize += size;
|
||||
} else {
|
||||
shallowFiles.push({ path: relPath, size });
|
||||
}
|
||||
}
|
||||
|
||||
// Print shallow files first
|
||||
for (const { path, size } of shallowFiles) {
|
||||
logger.log(`${formatFileSize(size).padStart(8)} ${path}`);
|
||||
}
|
||||
|
||||
// Print grouped directories
|
||||
for (const [dir, { files, totalSize }] of directoryGroups) {
|
||||
if (files.length === 1) {
|
||||
// If only one file in the group, print it normally
|
||||
const filePath = files[0];
|
||||
const fileSize = totalSize;
|
||||
logger.log(`${formatFileSize(fileSize).padStart(8)} ${filePath}`);
|
||||
} else {
|
||||
// Print directory summary
|
||||
logger.log(
|
||||
`${formatFileSize(totalSize).padStart(8)} ${dir}/ [and ${files.length} more files]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create zip
|
||||
const zipData = zipSync(files, {
|
||||
level: 9, // Maximum compression
|
||||
mtime: new Date(),
|
||||
});
|
||||
|
||||
// Write zip file
|
||||
writeFileSync(finalOutputPath, zipData);
|
||||
|
||||
// Calculate SHA sum
|
||||
const shasum = createHash("sha1").update(zipData).digest("hex");
|
||||
|
||||
// Print archive details
|
||||
const sanitizedName = sanitizeNameForFilename(manifest.name);
|
||||
const archiveName = `${sanitizedName}-${manifest.version}.dxt`;
|
||||
logger.log("\nArchive Details");
|
||||
logger.log(`name: ${manifest.name}`);
|
||||
logger.log(`version: ${manifest.version}`);
|
||||
logger.log(`filename: ${archiveName}`);
|
||||
logger.log(`package size: ${formatFileSize(zipData.length)}`);
|
||||
logger.log(`unpacked size: ${formatFileSize(totalUnpackedSize)}`);
|
||||
logger.log(`shasum: ${shasum}`);
|
||||
logger.log(`total files: ${fileEntries.length}`);
|
||||
logger.log(`ignored (.dxtignore) files: ${ignoredCount}`);
|
||||
|
||||
logger.log(`\nOutput: ${finalOutputPath}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error(`ERROR: Archive error: ${error.message}`);
|
||||
} else {
|
||||
logger.error("ERROR: Unknown archive error occurred");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
9
src/index.ts
Normal file
9
src/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Default export includes everything (backward compatibility)
|
||||
export * from "./cli/init.js";
|
||||
export * from "./cli/pack.js";
|
||||
export * from "./node/files.js";
|
||||
export * from "./node/sign.js";
|
||||
export * from "./node/validate.js";
|
||||
export * from "./schemas.js";
|
||||
export * from "./shared/config.js";
|
||||
export * from "./types.js";
|
||||
9
src/node.ts
Normal file
9
src/node.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Node.js-specific exports
|
||||
export * from "./node/files.js";
|
||||
export * from "./node/sign.js";
|
||||
export * from "./node/validate.js";
|
||||
|
||||
// Include all shared exports
|
||||
export * from "./schemas.js";
|
||||
export * from "./shared/config.js";
|
||||
export * from "./types.js";
|
||||
191
src/node/files.ts
Normal file
191
src/node/files.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
||||
import { basename, join, relative, sep } from "path";
|
||||
|
||||
// Files/patterns to exclude from the package
|
||||
export const EXCLUDE_PATTERNS = [
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
".gitignore",
|
||||
".git",
|
||||
".dxtignore",
|
||||
"*.log",
|
||||
".npm",
|
||||
".npmrc",
|
||||
".yarnrc",
|
||||
".yarn",
|
||||
".eslintrc",
|
||||
".editorconfig",
|
||||
".prettierrc",
|
||||
".prettierignore",
|
||||
".eslintignore",
|
||||
".nycrc",
|
||||
".babelrc",
|
||||
".pnp.*",
|
||||
"node_modules/.cache",
|
||||
"node_modules/.bin",
|
||||
"*.map",
|
||||
".env.local",
|
||||
".env.*.local",
|
||||
"npm-debug.log*",
|
||||
"yarn-debug.log*",
|
||||
"yarn-error.log*",
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"*.dxt",
|
||||
"*.d.ts",
|
||||
"*.tsbuildinfo",
|
||||
"tsconfig.json",
|
||||
];
|
||||
|
||||
/**
|
||||
* Read and parse .dxtignore file patterns
|
||||
*/
|
||||
export function readDxtIgnorePatterns(baseDir: string): string[] {
|
||||
const dxtIgnorePath = join(baseDir, ".dxtignore");
|
||||
if (!existsSync(dxtIgnorePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(dxtIgnorePath, "utf-8");
|
||||
return content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Warning: Could not read .dxtignore file: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldExclude(
|
||||
filePath: string,
|
||||
additionalPatterns: string[] = [],
|
||||
): boolean {
|
||||
const fileName = basename(filePath);
|
||||
const allPatterns = [...EXCLUDE_PATTERNS, ...additionalPatterns];
|
||||
|
||||
for (const pattern of allPatterns) {
|
||||
if (pattern.includes("*")) {
|
||||
// Enhanced glob matching to handle directory paths like "temp/*"
|
||||
let patternToMatch = pattern;
|
||||
|
||||
// Handle patterns like "dir/" by converting to "dir/**"
|
||||
if (pattern.endsWith("/")) {
|
||||
patternToMatch = pattern + "**";
|
||||
}
|
||||
|
||||
// Convert glob pattern to regex, with case sensitivity
|
||||
const regexPattern =
|
||||
"^" +
|
||||
patternToMatch
|
||||
.replace(/\./g, "\\.") // Escape dots
|
||||
.replace(/\*\*/g, ".*") // ** matches anything including /
|
||||
.replace(/\*/g, "[^/]*") + // * matches anything except /
|
||||
"$";
|
||||
|
||||
const regex = new RegExp(regexPattern);
|
||||
|
||||
// Test full path
|
||||
if (regex.test(filePath)) return true;
|
||||
|
||||
// Test filename
|
||||
if (regex.test(fileName)) return true;
|
||||
|
||||
// Check if any part of the path matches for patterns like "node_modules"
|
||||
const pathParts = filePath.split(sep);
|
||||
for (const part of pathParts) {
|
||||
if (regex.test(part)) return true;
|
||||
}
|
||||
} else {
|
||||
// Exact match
|
||||
if (fileName === pattern) return true;
|
||||
|
||||
// Check if any part of the path matches
|
||||
if (filePath.includes(pattern)) return true;
|
||||
|
||||
// Handle directory patterns like "tests/"
|
||||
if (pattern.endsWith("/") && filePath.startsWith(pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getAllFiles(
|
||||
dirPath: string,
|
||||
baseDir: string = dirPath,
|
||||
fileList: Record<string, Uint8Array> = {},
|
||||
additionalPatterns: string[] = [],
|
||||
): Record<string, Uint8Array> {
|
||||
const files = readdirSync(dirPath);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(dirPath, file);
|
||||
const relativePath = relative(baseDir, filePath);
|
||||
|
||||
if (shouldExclude(relativePath, additionalPatterns)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = statSync(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
getAllFiles(filePath, baseDir, fileList, additionalPatterns);
|
||||
} else {
|
||||
// Use forward slashes in zip file paths
|
||||
const zipPath = relativePath.split(sep).join("/");
|
||||
fileList[zipPath] = readFileSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return fileList;
|
||||
}
|
||||
|
||||
export interface GetAllFilesResult {
|
||||
files: Record<string, Uint8Array>;
|
||||
ignoredCount: number;
|
||||
}
|
||||
|
||||
export function getAllFilesWithCount(
|
||||
dirPath: string,
|
||||
baseDir: string = dirPath,
|
||||
fileList: Record<string, Uint8Array> = {},
|
||||
additionalPatterns: string[] = [],
|
||||
ignoredCount = 0,
|
||||
): GetAllFilesResult {
|
||||
const files = readdirSync(dirPath);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(dirPath, file);
|
||||
const relativePath = relative(baseDir, filePath);
|
||||
|
||||
if (shouldExclude(relativePath, additionalPatterns)) {
|
||||
ignoredCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = statSync(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const result = getAllFilesWithCount(
|
||||
filePath,
|
||||
baseDir,
|
||||
fileList,
|
||||
additionalPatterns,
|
||||
ignoredCount,
|
||||
);
|
||||
ignoredCount = result.ignoredCount;
|
||||
} else {
|
||||
// Use forward slashes in zip file paths
|
||||
const zipPath = relativePath.split(sep).join("/");
|
||||
fileList[zipPath] = readFileSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return { files: fileList, ignoredCount };
|
||||
}
|
||||
409
src/node/sign.ts
Normal file
409
src/node/sign.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
import { execFile } from "child_process";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { mkdtemp, rm, writeFile } from "fs/promises";
|
||||
import forge from "node-forge";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { promisify } from "util";
|
||||
|
||||
import type { DxtSignatureInfo } from "../types.js";
|
||||
|
||||
// Signature block markers
|
||||
const SIGNATURE_HEADER = "DXT_SIG_V1";
|
||||
const SIGNATURE_FOOTER = "DXT_SIG_END";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Signs a DXT file with the given certificate and private key using PKCS#7
|
||||
*
|
||||
* @param dxtPath Path to the DXT file to sign
|
||||
* @param certPath Path to the certificate file (PEM format)
|
||||
* @param keyPath Path to the private key file (PEM format)
|
||||
* @param intermediates Optional array of intermediate certificate paths
|
||||
*/
|
||||
export function signDxtFile(
|
||||
dxtPath: string,
|
||||
certPath: string,
|
||||
keyPath: string,
|
||||
intermediates?: string[],
|
||||
): void {
|
||||
// Read the original DXT file
|
||||
const dxtContent = readFileSync(dxtPath);
|
||||
|
||||
// Read certificate and key
|
||||
const certificatePem = readFileSync(certPath, "utf-8");
|
||||
const privateKeyPem = readFileSync(keyPath, "utf-8");
|
||||
|
||||
// Read intermediate certificates if provided
|
||||
const intermediatePems = intermediates?.map((path) =>
|
||||
readFileSync(path, "utf-8"),
|
||||
);
|
||||
|
||||
// Create PKCS#7 signed data
|
||||
const p7 = forge.pkcs7.createSignedData();
|
||||
p7.content = forge.util.createBuffer(dxtContent);
|
||||
|
||||
// Parse and add certificates
|
||||
const signingCert = forge.pki.certificateFromPem(certificatePem);
|
||||
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||
|
||||
p7.addCertificate(signingCert);
|
||||
|
||||
// Add intermediate certificates
|
||||
if (intermediatePems) {
|
||||
for (const pem of intermediatePems) {
|
||||
p7.addCertificate(forge.pki.certificateFromPem(pem));
|
||||
}
|
||||
}
|
||||
|
||||
// Add signer
|
||||
p7.addSigner({
|
||||
key: privateKey,
|
||||
certificate: signingCert,
|
||||
digestAlgorithm: forge.pki.oids.sha256,
|
||||
authenticatedAttributes: [
|
||||
{
|
||||
type: forge.pki.oids.contentType,
|
||||
value: forge.pki.oids.data,
|
||||
},
|
||||
{
|
||||
type: forge.pki.oids.messageDigest,
|
||||
// Value will be auto-populated
|
||||
},
|
||||
{
|
||||
type: forge.pki.oids.signingTime,
|
||||
// Value will be auto-populated with current time
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Sign with detached signature
|
||||
p7.sign({ detached: true });
|
||||
|
||||
// Convert to DER format
|
||||
const asn1 = forge.asn1.toDer(p7.toAsn1());
|
||||
const pkcs7Signature = Buffer.from(asn1.getBytes(), "binary");
|
||||
|
||||
// Create signature block with PKCS#7 data
|
||||
const signatureBlock = createSignatureBlock(pkcs7Signature);
|
||||
|
||||
// Append signature block to DXT file
|
||||
const signedContent = Buffer.concat([dxtContent, signatureBlock]);
|
||||
writeFileSync(dxtPath, signedContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a signed DXT file using OS certificate store
|
||||
*
|
||||
* @param dxtPath Path to the signed DXT file
|
||||
* @returns Signature information including verification status
|
||||
*/
|
||||
export async function verifyDxtFile(
|
||||
dxtPath: string,
|
||||
): Promise<DxtSignatureInfo> {
|
||||
try {
|
||||
const fileContent = readFileSync(dxtPath);
|
||||
|
||||
// Find and extract signature block
|
||||
const { originalContent, pkcs7Signature } =
|
||||
extractSignatureBlock(fileContent);
|
||||
if (!pkcs7Signature) {
|
||||
return { status: "unsigned" };
|
||||
}
|
||||
|
||||
// Parse PKCS#7 signature
|
||||
const asn1 = forge.asn1.fromDer(pkcs7Signature.toString("binary"));
|
||||
const p7Message = forge.pkcs7.messageFromAsn1(asn1);
|
||||
|
||||
// Verify it's signed data and cast to correct type
|
||||
if (
|
||||
!("type" in p7Message) ||
|
||||
p7Message.type !== forge.pki.oids.signedData
|
||||
) {
|
||||
return { status: "unsigned" };
|
||||
}
|
||||
|
||||
// Now we know it's PkcsSignedData. The types are incorrect, so we'll
|
||||
// fix them there
|
||||
const p7 = p7Message as unknown as forge.pkcs7.PkcsSignedData & {
|
||||
signerInfos: Array<{
|
||||
authenticatedAttributes: Array<{
|
||||
type: string;
|
||||
value: unknown;
|
||||
}>;
|
||||
}>;
|
||||
verify: (options?: { authenticatedAttributes?: boolean }) => boolean;
|
||||
};
|
||||
|
||||
// Extract certificates from PKCS#7
|
||||
const certificates = p7.certificates || [];
|
||||
if (certificates.length === 0) {
|
||||
return { status: "unsigned" };
|
||||
}
|
||||
|
||||
// Get the signing certificate (first one)
|
||||
const signingCert = certificates[0];
|
||||
|
||||
// Verify PKCS#7 signature
|
||||
const contentBuf = forge.util.createBuffer(originalContent);
|
||||
|
||||
try {
|
||||
p7.verify({ authenticatedAttributes: true });
|
||||
|
||||
// Also verify the content matches
|
||||
const signerInfos = p7.signerInfos;
|
||||
const signerInfo = signerInfos?.[0];
|
||||
if (signerInfo) {
|
||||
const md = forge.md.sha256.create();
|
||||
md.update(contentBuf.getBytes());
|
||||
const digest = md.digest().getBytes();
|
||||
|
||||
// Find the message digest attribute
|
||||
let messageDigest = null;
|
||||
for (const attr of signerInfo.authenticatedAttributes) {
|
||||
if (attr.type === forge.pki.oids.messageDigest) {
|
||||
messageDigest = attr.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!messageDigest || messageDigest !== digest) {
|
||||
return { status: "unsigned" };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { status: "unsigned" };
|
||||
}
|
||||
|
||||
// Convert forge certificate to PEM for OS verification
|
||||
const certPem = forge.pki.certificateToPem(signingCert);
|
||||
const intermediatePems = certificates
|
||||
.slice(1)
|
||||
.map((cert) => Buffer.from(forge.pki.certificateToPem(cert)));
|
||||
|
||||
// Verify certificate chain against OS trust store
|
||||
const chainValid = await verifyCertificateChain(
|
||||
Buffer.from(certPem),
|
||||
intermediatePems,
|
||||
);
|
||||
|
||||
if (!chainValid) {
|
||||
// Signature is valid but certificate is not trusted
|
||||
return { status: "unsigned" };
|
||||
}
|
||||
|
||||
// Extract certificate info
|
||||
const isSelfSigned =
|
||||
signingCert.issuer.getField("CN")?.value ===
|
||||
signingCert.subject.getField("CN")?.value;
|
||||
|
||||
return {
|
||||
status: isSelfSigned ? "self-signed" : "signed",
|
||||
publisher: signingCert.subject.getField("CN")?.value || "Unknown",
|
||||
issuer: signingCert.issuer.getField("CN")?.value || "Unknown",
|
||||
valid_from: signingCert.validity.notBefore.toISOString(),
|
||||
valid_to: signingCert.validity.notAfter.toISOString(),
|
||||
fingerprint: forge.md.sha256
|
||||
.create()
|
||||
.update(
|
||||
forge.asn1.toDer(forge.pki.certificateToAsn1(signingCert)).getBytes(),
|
||||
)
|
||||
.digest()
|
||||
.toHex(),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to verify DXT file: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a signature block buffer with PKCS#7 signature
|
||||
*/
|
||||
function createSignatureBlock(pkcs7Signature: Buffer): Buffer {
|
||||
const parts: Buffer[] = [];
|
||||
|
||||
// Header
|
||||
parts.push(Buffer.from(SIGNATURE_HEADER, "utf-8"));
|
||||
|
||||
// PKCS#7 signature length and data
|
||||
const sigLengthBuffer = Buffer.alloc(4);
|
||||
sigLengthBuffer.writeUInt32LE(pkcs7Signature.length, 0);
|
||||
parts.push(sigLengthBuffer);
|
||||
parts.push(pkcs7Signature);
|
||||
|
||||
// Footer
|
||||
parts.push(Buffer.from(SIGNATURE_FOOTER, "utf-8"));
|
||||
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the signature block from a signed DXT file
|
||||
*/
|
||||
function extractSignatureBlock(fileContent: Buffer): {
|
||||
originalContent: Buffer;
|
||||
pkcs7Signature?: Buffer;
|
||||
} {
|
||||
// Look for signature footer at the end
|
||||
const footerBytes = Buffer.from(SIGNATURE_FOOTER, "utf-8");
|
||||
const footerIndex = fileContent.lastIndexOf(footerBytes);
|
||||
|
||||
if (footerIndex === -1) {
|
||||
return { originalContent: fileContent };
|
||||
}
|
||||
|
||||
// Look for signature header before footer
|
||||
const headerBytes = Buffer.from(SIGNATURE_HEADER, "utf-8");
|
||||
let headerIndex = -1;
|
||||
|
||||
// Search backwards from footer
|
||||
for (let i = footerIndex - 1; i >= 0; i--) {
|
||||
if (fileContent.slice(i, i + headerBytes.length).equals(headerBytes)) {
|
||||
headerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (headerIndex === -1) {
|
||||
return { originalContent: fileContent };
|
||||
}
|
||||
|
||||
// Extract original content (everything before signature block)
|
||||
const originalContent = fileContent.slice(0, headerIndex);
|
||||
|
||||
// Parse signature block
|
||||
let offset = headerIndex + headerBytes.length;
|
||||
|
||||
try {
|
||||
// Read PKCS#7 signature length
|
||||
const sigLength = fileContent.readUInt32LE(offset);
|
||||
offset += 4;
|
||||
|
||||
// Read PKCS#7 signature
|
||||
const pkcs7Signature = fileContent.slice(offset, offset + sigLength);
|
||||
|
||||
return {
|
||||
originalContent,
|
||||
pkcs7Signature,
|
||||
};
|
||||
} catch {
|
||||
return { originalContent: fileContent };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies certificate chain against OS trust store
|
||||
*/
|
||||
export async function verifyCertificateChain(
|
||||
certificate: Buffer,
|
||||
intermediates?: Buffer[],
|
||||
): Promise<boolean> {
|
||||
let tempDir: string | null = null;
|
||||
|
||||
try {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "dxt-verify-"));
|
||||
const certChainPath = join(tempDir, "chain.pem");
|
||||
const certChain = [certificate, ...(intermediates || [])].join("\n");
|
||||
await writeFile(certChainPath, certChain);
|
||||
|
||||
// Platform-specific verification
|
||||
if (process.platform === "darwin") {
|
||||
try {
|
||||
await execFileAsync("security", [
|
||||
"verify-cert",
|
||||
"-c",
|
||||
certChainPath,
|
||||
"-p",
|
||||
"codeSign",
|
||||
]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
} else if (process.platform === "win32") {
|
||||
const psCommand = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$certCollection = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection
|
||||
$certCollection.Import('${certChainPath}')
|
||||
|
||||
if ($certCollection.Count -eq 0) {
|
||||
Write-Error 'No certificates found'
|
||||
exit 1
|
||||
}
|
||||
|
||||
$leafCert = $certCollection[0]
|
||||
$chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain
|
||||
|
||||
# Enable revocation checking
|
||||
$chain.ChainPolicy.RevocationMode = 'Online'
|
||||
$chain.ChainPolicy.RevocationFlag = 'EntireChain'
|
||||
$chain.ChainPolicy.UrlRetrievalTimeout = New-TimeSpan -Seconds 30
|
||||
|
||||
# Add code signing application policy
|
||||
$codeSignOid = New-Object System.Security.Cryptography.Oid '1.3.6.1.5.5.7.3.3'
|
||||
$chain.ChainPolicy.ApplicationPolicy.Add($codeSignOid)
|
||||
|
||||
# Add intermediate certificates to extra store
|
||||
for ($i = 1; $i -lt $certCollection.Count; $i++) {
|
||||
[void]$chain.ChainPolicy.ExtraStore.Add($certCollection[$i])
|
||||
}
|
||||
|
||||
# Build and validate chain
|
||||
$result = $chain.Build($leafCert)
|
||||
|
||||
if ($result) {
|
||||
'Valid'
|
||||
} else {
|
||||
$chain.ChainStatus | ForEach-Object {
|
||||
Write-Error "$($_.Status): $($_.StatusInformation)"
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
`.trim();
|
||||
|
||||
const { stdout } = await execFileAsync("powershell.exe", [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
psCommand,
|
||||
]);
|
||||
|
||||
return stdout.includes("Valid");
|
||||
} else {
|
||||
// Linux: Use openssl
|
||||
try {
|
||||
await execFileAsync("openssl", [
|
||||
"verify",
|
||||
"-purpose",
|
||||
"codesigning",
|
||||
"-CApath",
|
||||
"/etc/ssl/certs",
|
||||
certChainPath,
|
||||
]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return false;
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
try {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes signature from a DXT file
|
||||
*/
|
||||
export function unsignDxtFile(dxtPath: string): void {
|
||||
const fileContent = readFileSync(dxtPath);
|
||||
const { originalContent } = extractSignatureBlock(fileContent);
|
||||
writeFileSync(dxtPath, originalContent);
|
||||
}
|
||||
52
src/node/validate.ts
Normal file
52
src/node/validate.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { join, resolve } from "path";
|
||||
|
||||
import { DxtManifestSchema } from "../schemas.js";
|
||||
|
||||
export function validateManifest(inputPath: string): boolean {
|
||||
try {
|
||||
const resolvedPath = resolve(inputPath);
|
||||
let manifestPath = resolvedPath;
|
||||
|
||||
// If input is a directory, look for manifest.json inside it
|
||||
if (existsSync(resolvedPath) && statSync(resolvedPath).isDirectory()) {
|
||||
manifestPath = join(resolvedPath, "manifest.json");
|
||||
}
|
||||
|
||||
const manifestContent = readFileSync(manifestPath, "utf-8");
|
||||
const manifestData = JSON.parse(manifestContent);
|
||||
|
||||
const result = DxtManifestSchema.safeParse(manifestData);
|
||||
|
||||
if (result.success) {
|
||||
console.log("Manifest is valid!");
|
||||
return true;
|
||||
} else {
|
||||
console.log("ERROR: Manifest validation failed:\n");
|
||||
result.error.issues.forEach((issue) => {
|
||||
const path = issue.path.join(".");
|
||||
console.log(` - ${path ? `${path}: ` : ""}${issue.message}`);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("ENOENT")) {
|
||||
console.error(`ERROR: File not found: ${inputPath}`);
|
||||
if (
|
||||
existsSync(resolve(inputPath)) &&
|
||||
statSync(resolve(inputPath)).isDirectory()
|
||||
) {
|
||||
console.error(` (No manifest.json found in directory)`);
|
||||
}
|
||||
} else if (error.message.includes("JSON")) {
|
||||
console.error(`ERROR: Invalid JSON in manifest file: ${error.message}`);
|
||||
} else {
|
||||
console.error(`ERROR: Error reading manifest: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
console.error("ERROR: Unknown error occurred");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
101
src/schemas.ts
Normal file
101
src/schemas.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const McpServerConfigSchema = z.object({
|
||||
command: z.string(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const DxtManifestAuthorSchema = z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email().optional(),
|
||||
url: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export const DxtManifestRepositorySchema = z.object({
|
||||
type: z.string(),
|
||||
url: z.string().url(),
|
||||
});
|
||||
|
||||
export const DxtManifestPlatformOverrideSchema =
|
||||
McpServerConfigSchema.partial();
|
||||
|
||||
export const DxtManifestMcpConfigSchema = McpServerConfigSchema.extend({
|
||||
platform_overrides: z
|
||||
.record(z.string(), DxtManifestPlatformOverrideSchema)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const DxtManifestServerSchema = z.object({
|
||||
type: z.enum(["python", "node", "binary"]),
|
||||
entry_point: z.string(),
|
||||
mcp_config: DxtManifestMcpConfigSchema,
|
||||
});
|
||||
|
||||
export const DxtManifestCompatibilitySchema = z
|
||||
.object({
|
||||
claude_desktop: z.string().optional(),
|
||||
platforms: z.array(z.enum(["darwin", "win32", "linux"])).optional(),
|
||||
runtimes: z
|
||||
.object({
|
||||
python: z.string().optional(),
|
||||
node: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const DxtManifestToolSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const DxtManifestPromptSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
arguments: z.array(z.string()).optional(),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export const DxtUserConfigurationOptionSchema = z.object({
|
||||
type: z.enum(["string", "number", "boolean", "directory", "file"]),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
required: z.boolean().optional(),
|
||||
default: z
|
||||
.union([z.string(), z.number(), z.boolean(), z.array(z.string())])
|
||||
.optional(),
|
||||
multiple: z.boolean().optional(),
|
||||
sensitive: z.boolean().optional(),
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
});
|
||||
|
||||
export const DxtUserConfigValuesSchema = z.record(
|
||||
z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]),
|
||||
);
|
||||
|
||||
export const DxtManifestSchema = z.object({
|
||||
dxt_version: z.string(),
|
||||
name: z.string(),
|
||||
display_name: z.string().optional(),
|
||||
version: z.string(),
|
||||
description: z.string(),
|
||||
long_description: z.string().optional(),
|
||||
author: DxtManifestAuthorSchema,
|
||||
repository: DxtManifestRepositorySchema.optional(),
|
||||
homepage: z.string().url().optional(),
|
||||
documentation: z.string().url().optional(),
|
||||
support: z.string().url().optional(),
|
||||
icon: z.string().optional(),
|
||||
screenshots: z.array(z.string()).optional(),
|
||||
server: DxtManifestServerSchema,
|
||||
tools: z.array(DxtManifestToolSchema).optional(),
|
||||
tools_generated: z.boolean().optional(),
|
||||
prompts: z.array(DxtManifestPromptSchema).optional(),
|
||||
prompts_generated: z.boolean().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
license: z.string().optional(),
|
||||
compatibility: DxtManifestCompatibilitySchema.optional(),
|
||||
user_config: z.record(DxtUserConfigurationOptionSchema).optional(),
|
||||
});
|
||||
217
src/shared/config.ts
Normal file
217
src/shared/config.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import type {
|
||||
DxtManifest,
|
||||
DxtUserConfigValues,
|
||||
Logger,
|
||||
McpServerConfig,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* This file contains utility functions for handling DXT configuration,
|
||||
* including variable replacement and MCP server configuration generation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Recursively replaces variables in any value. Handles strings, arrays, and objects.
|
||||
*
|
||||
* @param value The value to process
|
||||
* @param variables Object containing variable replacements
|
||||
* @returns The processed value with all variables replaced
|
||||
*/
|
||||
export function replaceVariables(
|
||||
value: unknown,
|
||||
variables: Record<string, string | string[]>,
|
||||
): unknown {
|
||||
if (typeof value === "string") {
|
||||
let result = value;
|
||||
|
||||
// Replace all variables in the string
|
||||
for (const [key, replacement] of Object.entries(variables)) {
|
||||
const pattern = new RegExp(`\\$\\{${key}\\}`, "g");
|
||||
|
||||
// Check if this pattern actually exists in the string
|
||||
if (result.match(pattern)) {
|
||||
if (Array.isArray(replacement)) {
|
||||
console.warn(
|
||||
`Cannot replace ${key} with array value in string context: "${value}"`,
|
||||
{ key, replacement },
|
||||
);
|
||||
} else {
|
||||
result = result.replace(pattern, replacement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} else if (Array.isArray(value)) {
|
||||
// For arrays, we need to handle special case of array expansion
|
||||
const result: unknown[] = [];
|
||||
|
||||
for (const item of value) {
|
||||
if (
|
||||
typeof item === "string" &&
|
||||
item.match(/^\$\{user_config\.[^}]+\}$/)
|
||||
) {
|
||||
// This is a user config variable that might expand to multiple values
|
||||
const varName = item.match(/^\$\{([^}]+)\}$/)?.[1];
|
||||
if (varName && variables[varName]) {
|
||||
const replacement = variables[varName];
|
||||
if (Array.isArray(replacement)) {
|
||||
// Expand array inline
|
||||
result.push(...replacement);
|
||||
} else {
|
||||
result.push(replacement);
|
||||
}
|
||||
} else {
|
||||
// Variable not found, keep original
|
||||
result.push(item);
|
||||
}
|
||||
} else {
|
||||
// Recursively process non-variable items
|
||||
result.push(replaceVariables(item, variables));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} else if (value && typeof value === "object") {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
result[key] = replaceVariables(val, variables);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
interface GetMcpConfigForManifestOptions {
|
||||
manifest: DxtManifest;
|
||||
extensionPath: string;
|
||||
systemDirs: Record<string, string>;
|
||||
userConfig: DxtUserConfigValues;
|
||||
pathSeparator: string;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
export async function getMcpConfigForManifest(
|
||||
options: GetMcpConfigForManifestOptions,
|
||||
): Promise<McpServerConfig | undefined> {
|
||||
const {
|
||||
manifest,
|
||||
extensionPath,
|
||||
systemDirs,
|
||||
userConfig,
|
||||
pathSeparator,
|
||||
logger,
|
||||
} = options;
|
||||
const baseConfig = manifest.server?.mcp_config;
|
||||
if (!baseConfig) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let result: McpServerConfig = {
|
||||
...baseConfig,
|
||||
};
|
||||
|
||||
if (baseConfig.platform_overrides) {
|
||||
if (process.platform in baseConfig.platform_overrides) {
|
||||
const platformConfig = baseConfig.platform_overrides[process.platform];
|
||||
|
||||
result.command = platformConfig.command || result.command;
|
||||
result.args = platformConfig.args || result.args;
|
||||
result.env = platformConfig.env || result.env;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if required configuration is missing
|
||||
if (hasRequiredConfigMissing({ manifest, userConfig })) {
|
||||
logger?.warn(
|
||||
`Extension ${manifest.name} has missing required configuration, skipping MCP config`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const variables: Record<string, string | string[]> = {
|
||||
__dirname: extensionPath,
|
||||
pathSeparator,
|
||||
"/": pathSeparator,
|
||||
...systemDirs,
|
||||
};
|
||||
|
||||
// Build merged configuration from defaults and user settings
|
||||
const mergedConfig: Record<string, unknown> = {};
|
||||
|
||||
// First, add defaults from manifest
|
||||
if (manifest.user_config) {
|
||||
for (const [key, configOption] of Object.entries(manifest.user_config)) {
|
||||
if (configOption.default !== undefined) {
|
||||
mergedConfig[key] = configOption.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then, override with user settings
|
||||
if (userConfig) {
|
||||
Object.assign(mergedConfig, userConfig);
|
||||
}
|
||||
|
||||
// Add merged configuration variables for substitution
|
||||
for (const [key, value] of Object.entries(mergedConfig)) {
|
||||
// Convert user config to the format expected by variable substitution
|
||||
const userConfigKey = `user_config.${key}`;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// Keep arrays as arrays for proper expansion
|
||||
variables[userConfigKey] = value.map(String);
|
||||
} else if (typeof value === "boolean") {
|
||||
// Convert booleans to "true"/"false" strings as per spec
|
||||
variables[userConfigKey] = value ? "true" : "false";
|
||||
} else {
|
||||
// Convert other types to strings
|
||||
variables[userConfigKey] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace all variables in the config
|
||||
result = replaceVariables(result, variables) as McpServerConfig;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
interface HasRequiredConfigMissingOptions {
|
||||
manifest: DxtManifest;
|
||||
userConfig?: DxtUserConfigValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an extension has missing required configuration
|
||||
* @param manifest The extension manifest
|
||||
* @param userConfig The user configuration
|
||||
* @returns true if required configuration is missing
|
||||
*/
|
||||
export function hasRequiredConfigMissing({
|
||||
manifest,
|
||||
userConfig,
|
||||
}: HasRequiredConfigMissingOptions): boolean {
|
||||
if (!manifest.user_config) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const config = userConfig || {};
|
||||
|
||||
for (const [key, configOption] of Object.entries(manifest.user_config)) {
|
||||
if (configOption.required) {
|
||||
const value = config[key];
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === "" ||
|
||||
(Array.isArray(value) && value.length === 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
33
src/shared/log.ts
Normal file
33
src/shared/log.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
interface LoggerOptions {
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
export function getLogger({ silent = false }: LoggerOptions = {}) {
|
||||
return {
|
||||
log: (...args: unknown[]) => {
|
||||
if (!silent) {
|
||||
console.log(...args);
|
||||
}
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
if (!silent) {
|
||||
console.error(...args);
|
||||
}
|
||||
},
|
||||
warn: (...args: unknown[]) => {
|
||||
if (!silent) {
|
||||
console.warn(...args);
|
||||
}
|
||||
},
|
||||
info: (...args: unknown[]) => {
|
||||
if (!silent) {
|
||||
console.info(...args);
|
||||
}
|
||||
},
|
||||
debug: (...args: unknown[]) => {
|
||||
if (!silent) {
|
||||
console.debug(...args);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
64
src/types.ts
Normal file
64
src/types.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { z } from "zod";
|
||||
|
||||
import type {
|
||||
DxtManifestAuthorSchema,
|
||||
DxtManifestCompatibilitySchema,
|
||||
DxtManifestMcpConfigSchema,
|
||||
DxtManifestPlatformOverrideSchema,
|
||||
DxtManifestPromptSchema,
|
||||
DxtManifestRepositorySchema,
|
||||
DxtManifestSchema,
|
||||
DxtManifestServerSchema,
|
||||
DxtManifestToolSchema,
|
||||
DxtUserConfigurationOptionSchema,
|
||||
DxtUserConfigValuesSchema,
|
||||
McpServerConfigSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
export type McpServerConfig = z.infer<typeof McpServerConfigSchema>;
|
||||
|
||||
export type DxtManifestAuthor = z.infer<typeof DxtManifestAuthorSchema>;
|
||||
|
||||
export type DxtManifestRepository = z.infer<typeof DxtManifestRepositorySchema>;
|
||||
|
||||
export type DxtManifestPlatformOverride = z.infer<
|
||||
typeof DxtManifestPlatformOverrideSchema
|
||||
>;
|
||||
|
||||
export type DxtManifestMcpConfig = z.infer<typeof DxtManifestMcpConfigSchema>;
|
||||
|
||||
export type DxtManifestServer = z.infer<typeof DxtManifestServerSchema>;
|
||||
|
||||
export type DxtManifestCompatibility = z.infer<
|
||||
typeof DxtManifestCompatibilitySchema
|
||||
>;
|
||||
|
||||
export type DxtManifestTool = z.infer<typeof DxtManifestToolSchema>;
|
||||
|
||||
export type DxtManifestPrompt = z.infer<typeof DxtManifestPromptSchema>;
|
||||
|
||||
export type DxtUserConfigurationOption = z.infer<
|
||||
typeof DxtUserConfigurationOptionSchema
|
||||
>;
|
||||
|
||||
export type DxtUserConfigValues = z.infer<typeof DxtUserConfigValuesSchema>;
|
||||
|
||||
export type DxtManifest = z.infer<typeof DxtManifestSchema>;
|
||||
|
||||
/**
|
||||
* Information about a DXT package signature
|
||||
*/
|
||||
export interface DxtSignatureInfo {
|
||||
status: "signed" | "unsigned" | "self-signed";
|
||||
publisher?: string;
|
||||
issuer?: string;
|
||||
valid_from?: string;
|
||||
valid_to?: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
log: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
}
|
||||
Loading…
Reference in a new issue