Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/chatgpt-plugin-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ package.
1. Validate `plugins/chatgpt/plugin.json` and `mcp.json` against the current
OpenAI plugin schemas. Confirm every referenced asset exists and uses the
approved Socket brand asset.
2. Build the upload ZIP from `plugins/chatgpt/`, with `plugin.json`, `mcp.json`,
and `assets/icon.png` at the archive root. Keep the generated ZIP outside
the repository.
2. Run `pnpm run plugin:chatgpt:pack` to check the package and build the
upload ZIP, with `plugin.json`, `mcp.json`, and `assets/icon.png` at the
archive root. The ZIP goes to the OS temp directory, outside the
repository. Add `--strict` before submission to fail on an unmet
submission gate, and `--out <dir>` to choose the destination.
3. Install the package in ChatGPT developer mode and sign in to Socket.
Exercise the positive and negative review cases in `plugin.json`, including
account isolation, expired credentials, reconnect, and unsupported
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"check": "pnpm run fmt:check && pnpm run lint && pnpm run repo:check && pnpm run build-mcpb:versions_match && pnpm run repo:scan",
"mcp:schema:check": "mcp-tada check --command node --arg index.mts --env SOCKET_API_TOKEN=YOUR_API_TOKEN --env MCP_HTTP_MODE=false --timeout 5000 --against test/repo/integration/server-schema.golden.json",
"mcp:schema:update": "mcp-tada introspect --command node --arg index.mts --env SOCKET_API_TOKEN=YOUR_API_TOKEN --env MCP_HTTP_MODE=false --timeout 5000 --json --out test/repo/integration/server-schema.golden.json && pnpm run fmt test/repo/integration/server-schema.golden.json",
"plugin:chatgpt:pack": "node scripts/repo/plugin/chatgpt/pack.mts",
"postpublish": "pnpm run clean",
"prepare": "node scripts/repo/install-git-hooks.mts",
"prepublishOnly": "node -e \"if (process.env.GITHUB_ACTIONS !== 'true') throw new Error('Publish through the npm workflow')\"",
Expand Down
7 changes: 7 additions & 0 deletions scripts/repo/paths.mts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export const DIST_DIR = path.join(REPO_ROOT, 'dist')

export const SERVER_SOURCE = path.join(REPO_ROOT, 'index.mts')

/**
* Absolute path to the ChatGPT plugin package source: the listing manifest,
* the MCP server configuration, and the listing assets. The upload ZIP is
* built from this directory and written outside the repository.
*/
export const PLUGIN_CHATGPT_DIR = path.join(REPO_ROOT, 'plugins', 'chatgpt')

/**
* Absolute path to the socket-gate hook's SOURCE directory: the hook source
* (`index.mts`) and its README. Dev/test content only — the published,
Expand Down
152 changes: 152 additions & 0 deletions scripts/repo/plugin/chatgpt/pack.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env node

/**
* @file Validate `plugins/chatgpt/` against the OpenAI plugin submission
* requirements and write the upload ZIP. The archive holds `plugin.json`,
* `mcp.json`, and `assets/icon.png` at its root, and is written OUTSIDE the
* repository (default: the OS temp directory) so a build artifact never
* lands in the tracked tree.
* The rules live in `requirements.mts`; this file is their IO shell. An
* error blocks the ZIP, a warning does not, and `--strict` promotes
* warnings to errors for the pre-submission gate.
*/

import { readFile, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'

import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'

import {
chatgptManifestVersion,
ICON_ENTRY_NAME,
inspectChatgptPluginPackage,
MCP_CONFIG_NAME,
PLUGIN_MANIFEST_NAME,
} from './requirements.mts'
import { createZipArchive } from './zip.mts'
import { isMainModule } from '../../../fleet/process/is-main-module.mts'
import { runMain } from '../../../fleet/process/main/run.mts'
import { isJsonRequested } from '../../../fleet/process/script-meta.mts'
import { PLUGIN_CHATGPT_DIR } from '../../paths.mts'
import type { ChatgptPluginFindings } from './requirements.mts'
import type { ScriptMeta } from '../../../fleet/process/main/run.mts'
import type { ScriptResult } from '../../../fleet/process/script-result.mts'

const logger = getDefaultLogger()

export interface ChatgptPackConfig {
readonly outputDir: string
readonly strict: boolean
}

export interface ChatgptPackResult {
readonly bytes: number
readonly findings: ChatgptPluginFindings
readonly outputPath: string | undefined
}

/**
* Read `plugins/chatgpt/`, check it, and write the upload ZIP when nothing
* blocks. Returns the findings either way so the caller sets the exit code.
*/
export async function packChatgptPlugin(
config: ChatgptPackConfig,
): Promise<ChatgptPackResult> {
const { outputDir, strict } = { __proto__: null, ...config }
const manifestPath = path.join(PLUGIN_CHATGPT_DIR, PLUGIN_MANIFEST_NAME)
const mcpConfigPath = path.join(PLUGIN_CHATGPT_DIR, MCP_CONFIG_NAME)
const iconPath = path.join(PLUGIN_CHATGPT_DIR, 'assets', 'icon.png')

const {
0: pluginJson,
1: mcpJson,
2: iconBytes,
} = await Promise.all([
readFile(manifestPath, 'utf8'),
readFile(mcpConfigPath, 'utf8'),
readFile(iconPath),
])

const findings = inspectChatgptPluginPackage({
iconBytes,
mcpJson,
pluginJson,
})
const blocked =
findings.errors.length > 0 || (strict && findings.warnings.length > 0)
if (blocked) {
return { bytes: 0, findings, outputPath: undefined }
}

const archive = createZipArchive([
{ name: PLUGIN_MANIFEST_NAME, data: Buffer.from(pluginJson, 'utf8') },
{ name: MCP_CONFIG_NAME, data: Buffer.from(mcpJson, 'utf8') },
{ name: ICON_ENTRY_NAME, data: iconBytes },
])
const outputPath = path.join(
outputDir,
`socket-chatgpt-plugin-${chatgptManifestVersion(pluginJson)}.zip`,
)
await writeFile(outputPath, archive)
return { bytes: archive.length, findings, outputPath }
}

function readOutputDir(argv: readonly string[]): string {
const index = argv.indexOf('--out')
const value = index === -1 ? undefined : argv[index + 1]
return value === undefined ? os.tmpdir() : path.resolve(value)
}

// The payload rides back on ScriptResult.data so the fleet runner renders the
// one JSON document `json: 'result'` promises. Printing it here as well would
// put two documents on stdout and leave no parseable output.
export async function main(): Promise<ScriptResult> {
const argv = process.argv.slice(2)
const result = await packChatgptPlugin({
outputDir: readOutputDir(argv),
strict: argv.includes('--strict'),
})
const { errors, warnings } = result.findings
const blocked = result.outputPath === undefined
const data = {
bytes: result.bytes,
errors,
outputPath: result.outputPath,
warnings,
}

if (isJsonRequested(argv)) {
return { data, exitCode: blocked ? 1 : 0 }
}

for (const warning of warnings) {
logger.warn(warning)
}
for (const error of errors) {
logger.fail(error)
}
if (blocked) {
logger.fail(
`Wrote no ZIP. Where: ${PLUGIN_CHATGPT_DIR}. Saw ${errors.length} error(s) and ${warnings.length} warning(s); wanted a package that satisfies the OpenAI submission requirements. Fix: resolve the findings above.`,
)
return { data, exitCode: 1 }
}
logger.success(`Wrote ${result.outputPath} (${result.bytes} bytes).`)
return { data, exitCode: 0 }
}

const SCRIPT_META: ScriptMeta = {
describe:
'validates plugins/chatgpt against the OpenAI submission requirements and writes the upload ZIP',
help:
'Usage: pnpm run plugin:chatgpt:pack [--out <dir>] [--strict] [--json]\n\n' +
' --out <dir> Directory for the ZIP. Defaults to the OS temp directory,\n' +
' keeping the artifact outside the repository.\n' +
' --strict Treat submission warnings as errors. Use before submitting.\n' +
' --json Print the findings and output path as JSON.',
json: 'result',
}
if (isMainModule(import.meta.url)) {
runMain(main, SCRIPT_META)
}
Loading
Loading