mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-03 01:13:12 +08:00
The agent-SDK producer (build/agent-sdk/produce.ts -> buildOne in package.ts) runs its own scratch `npm ci` to cross-install each SDK's per-platform native package, which is declared as an *optional* dependency of a small base package (e.g. `@openai/codex` -> `@openai/codex-<target>`, `@anthropic-ai/claude-agent-sdk` -> `@anthropic-ai/claude-agent-sdk-<target>`). npm does not fail when an optional dependency can't be installed, so a transient registry hiccup can leave the base package present but the native package missing. On a real publish run that binary-less tarball would be uploaded to the immutable, content-addressed CDN path and product.json stamped to point at it, failing only at runtime for end users (see #323881). The cache guard added in #324064 does not cover this: it checks the repo-root node_modules for the host platform, whereas the release path packages a separate scratch tree for a (possibly foreign) target. Reuse the guard's core logic by extracting `findMissingNativeOptionalDep` from checkNativeOptionalDeps.ts and calling it in buildOne right after `npm ci` (before tarring), keyed on the exact base package and sdkTarget (including the `-musl` variants). Refactoring moves the CLI-only logic behind an `isCliInvocation()` guard so importing the module no longer runs the check or exits; the pr-node-modules.yml step is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
259 lines
9.8 KiB
TypeScript
259 lines
9.8 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
/**
|
|
* Builds one per-target tarball for one agent SDK. Callable as both a Node
|
|
* library function (`buildOne(...)`) and a thin CLI (the bottom of this file).
|
|
*
|
|
* The library form is what `produce.ts` calls during the per-platform
|
|
* "Agent SDK: build + upload" pipeline step; the CLI form is for local
|
|
* one-off builds during development.
|
|
*
|
|
* Runs on any OS — `npm install` with `npm_config_libc/os/cpu` set fetches
|
|
* the foreign platform's pre-built binary package as-is from the registry,
|
|
* no compilation involved. The tarball is then `tar`'d on whatever host is
|
|
* doing the packaging. Each `(sdk, target)` pair has exactly one producer
|
|
* per pipeline run (no cross-host race), so we don't need byte-identical
|
|
* tarballs across OSes — only across re-runs on the same host, which the
|
|
* same npm install + same tar version produces naturally.
|
|
*
|
|
* SDK version pinning:
|
|
* - Pinned via repo-root `package.json` devDeps (`getSdkVersion`).
|
|
* - No `node_modules` package-lock for the scratch install: transitive
|
|
* drift surfaces at upload time as a sha mismatch against the existing
|
|
* blob, where a human investigates.
|
|
*
|
|
* Uses node-tar (pure JS) for tar creation rather than system tar so that
|
|
* tarballs produced on a Windows or macOS host have the same shape as ones
|
|
* produced on Linux — same library, same flags, same output bytes given
|
|
* the same input tree.
|
|
*/
|
|
|
|
import { spawnSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import * as tar from 'tar';
|
|
import { findMissingNativeOptionalDep } from '../azure-pipelines/common/checkNativeOptionalDeps.ts';
|
|
import { getAgentDir, getAgentMeta, parseFlags, type Sdk, sha256OfFile } from './common.ts';
|
|
|
|
const SCRIPT = 'package.ts';
|
|
|
|
export interface IBuildResult {
|
|
readonly tgzPath: string;
|
|
readonly sha256: string;
|
|
readonly sdkVersion: string;
|
|
readonly sizeBytes: number;
|
|
}
|
|
|
|
export interface IBuildArgs {
|
|
readonly sdk: Sdk;
|
|
readonly sdkTarget: string;
|
|
readonly outDir: string;
|
|
}
|
|
|
|
/**
|
|
* Build one tarball. Copies the SDK's pinned `agents/<sdk>/{package.json,
|
|
* package-lock.json}` into a scratch dir, runs `npm ci` against the
|
|
* lockfile (byte-deterministic dep graph), chmods+normalises+tars the
|
|
* result. Returns the produced `.tgz` path and its sha256.
|
|
*
|
|
* Determinism comes from the lockfile + node-tar's portable mode. Two
|
|
* runs against the same lockfile on different hosts should produce the
|
|
* same bytes — that's what the CDN's HEAD-then-fail upload depends on.
|
|
*/
|
|
export async function buildOne(args: IBuildArgs): Promise<IBuildResult> {
|
|
const { name: packageName, version: sdkVersion } = getAgentMeta(args.sdk);
|
|
const agentDir = getAgentDir(args.sdk);
|
|
|
|
const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-sdk-pkg-'));
|
|
try {
|
|
// Copy the pinned package.json + package-lock.json into the scratch
|
|
// dir. `npm ci` errors out if a node_modules is already present, so
|
|
// the scratch dir starts clean.
|
|
fs.copyFileSync(path.join(agentDir, 'package.json'), path.join(stagingDir, 'package.json'));
|
|
fs.copyFileSync(path.join(agentDir, 'package-lock.json'), path.join(stagingDir, 'package-lock.json'));
|
|
|
|
console.log(`[${SCRIPT}] Building ${packageName}@${sdkVersion} for ${args.sdkTarget} in ${stagingDir}`);
|
|
|
|
const { os: targetOs, cpu, libc } = parseTargetTriple(args.sdkTarget);
|
|
const npmEnv: NodeJS.ProcessEnv = { npm_config_os: targetOs, npm_config_cpu: cpu };
|
|
if (libc) {
|
|
npmEnv.npm_config_libc = libc;
|
|
}
|
|
npmCi(stagingDir, npmEnv);
|
|
|
|
const nodeModulesDir = path.join(stagingDir, 'node_modules');
|
|
|
|
// The SDK ships its native binary in a per-platform package declared as
|
|
// an *optional* dependency (e.g. `@openai/codex-linux-x64`). npm does not
|
|
// fail when an optional dependency can't be installed, so a transient
|
|
// registry hiccup can leave the base package present but the native
|
|
// package missing — which would silently produce a binary-less tarball
|
|
// and upload it to the (immutable, content-addressed) CDN path, failing
|
|
// only at runtime for end users. Fail loud here instead.
|
|
// See https://github.com/microsoft/vscode/pull/323881.
|
|
const missingNativeDep = findMissingNativeOptionalDep(nodeModulesDir, packageName, args.sdkTarget);
|
|
if (missingNativeDep) {
|
|
throw new Error(`[${SCRIPT}] npm ci left ${packageName}@${sdkVersion} without its native package '${missingNativeDep}' for target ${args.sdkTarget} — the optional dependency was silently skipped. Refusing to build a binary-less tarball; re-run to re-fetch it.`);
|
|
}
|
|
|
|
chmodPlatformBinaries(nodeModulesDir, args.sdk);
|
|
|
|
fs.mkdirSync(args.outDir, { recursive: true });
|
|
const tgzPath = path.join(args.outDir, `${args.sdk}-${sdkVersion}-${args.sdkTarget}.tgz`);
|
|
await buildTarball(stagingDir, tgzPath);
|
|
|
|
const sha256 = await sha256OfFile(tgzPath);
|
|
const sizeBytes = fs.statSync(tgzPath).size;
|
|
|
|
console.log(`[${SCRIPT}] Wrote ${tgzPath} (${sizeBytes} bytes, sha256=${sha256})`);
|
|
return { tgzPath, sha256, sdkVersion, sizeBytes };
|
|
} finally {
|
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function parseTargetTriple(sdkTarget: string): { os: string; cpu: string; libc?: string } {
|
|
// `darwin-arm64`, `linux-x64`, `linux-x64-musl`, `win32-x64`, …
|
|
const match = /^([a-z0-9]+)-([a-z0-9]+)(?:-([a-z0-9]+))?$/.exec(sdkTarget);
|
|
if (!match) {
|
|
throw new Error(`[${SCRIPT}] Cannot parse target '${sdkTarget}'`);
|
|
}
|
|
const [, osStr, cpu, libc] = match;
|
|
return { os: osStr, cpu, libc };
|
|
}
|
|
|
|
|
|
/**
|
|
* Chmod the executable binaries inside a per-SDK extracted node_modules tree.
|
|
* Layout differs per SDK; we don't pretend it's configurable:
|
|
* - claude: a single top-level `claude` binary per platform package
|
|
* - codex: `vendor/<rust-triple>/bin/codex` under the platform package
|
|
*/
|
|
function chmodPlatformBinaries(nodeModulesDir: string, sdk: Sdk): void {
|
|
if (sdk === 'claude') {
|
|
const scopeDir = path.join(nodeModulesDir, '@anthropic-ai');
|
|
if (!fs.existsSync(scopeDir)) {
|
|
return;
|
|
}
|
|
for (const child of fs.readdirSync(scopeDir)) {
|
|
if (!child.startsWith('claude-agent-sdk-')) {
|
|
continue;
|
|
}
|
|
const binary = path.join(scopeDir, child, 'claude');
|
|
if (fs.existsSync(binary)) {
|
|
fs.chmodSync(binary, 0o755);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// codex
|
|
const scopeDir = path.join(nodeModulesDir, '@openai');
|
|
if (!fs.existsSync(scopeDir)) {
|
|
return;
|
|
}
|
|
for (const child of fs.readdirSync(scopeDir)) {
|
|
if (!child.startsWith('codex-')) {
|
|
continue;
|
|
}
|
|
const vendorDir = path.join(scopeDir, child, 'vendor');
|
|
if (!fs.existsSync(vendorDir)) {
|
|
continue;
|
|
}
|
|
for (const triple of fs.readdirSync(vendorDir)) {
|
|
const binDir = path.join(vendorDir, triple, 'bin');
|
|
if (!fs.existsSync(binDir)) {
|
|
continue;
|
|
}
|
|
for (const f of fs.readdirSync(binDir)) {
|
|
fs.chmodSync(path.join(binDir, f), 0o755);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function npmCi(workDir: string, env: NodeJS.ProcessEnv): void {
|
|
// `npm ci` instead of `npm install`: installs the EXACT graph from the
|
|
// committed package-lock.json without resolving versions, which is what
|
|
// makes the tarball bytes reproducible across pipeline runs.
|
|
// `--ignore-scripts` blocks any postinstall/preinstall the SDK or its
|
|
// transitive deps might ship.
|
|
// On Windows, npm is a `.cmd` shim. Two things matter:
|
|
// 1. The explicit `.cmd` suffix — Node won't resolve PATHEXT.
|
|
// 2. `shell: true` — since Node 20 (CVE-2024-27980) child_process
|
|
// refuses to spawn .cmd/.bat without it.
|
|
const isWindows = process.platform === 'win32';
|
|
const npm = isWindows ? 'npm.cmd' : 'npm';
|
|
const result = spawnSync(npm, ['ci', '--ignore-scripts'], {
|
|
cwd: workDir,
|
|
env: { ...process.env, ...env },
|
|
stdio: 'inherit',
|
|
shell: isWindows,
|
|
});
|
|
if (result.error) {
|
|
throw new Error(`[${SCRIPT}] npm ci failed to spawn: ${result.error.message}`);
|
|
}
|
|
if (result.status !== 0) {
|
|
throw new Error(`[${SCRIPT}] npm ci exited ${result.status}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds the gzipped tar via node-tar. Same library on every host, so the
|
|
* output is consistent regardless of whether GNU/BSD/Windows tar is what
|
|
* the host normally ships.
|
|
*/
|
|
async function buildTarball(stagingDir: string, outTgz: string): Promise<void> {
|
|
await tar.c(
|
|
{
|
|
file: outTgz,
|
|
cwd: stagingDir,
|
|
gzip: { level: 9 },
|
|
portable: true, // omit user/group names and similar host-specific metadata
|
|
mtime: new Date(0),
|
|
},
|
|
['node_modules'],
|
|
);
|
|
}
|
|
|
|
// #region CLI entry point
|
|
//
|
|
// Lets a developer run `node build/agent-sdk/package.ts --sdk=claude
|
|
// --target=darwin-arm64 --out=/tmp/out` to produce one tarball locally.
|
|
// The gulpfile-side packaging uses `buildOne()` directly.
|
|
|
|
function isCliInvocation(): boolean {
|
|
// `import.meta.filename` is already a real filesystem path; comparing
|
|
// it directly to `process.argv[1]` works on Windows (where the
|
|
// manual `file://${argv}` construction breaks because Node URL-encodes
|
|
// drive letters and spaces). Pattern matches `build/npm/installStateHash.ts:143`.
|
|
return import.meta.filename === process.argv[1];
|
|
}
|
|
|
|
function parseCliArgs(): IBuildArgs {
|
|
const flags = parseFlags(process.argv.slice(2));
|
|
const sdk = flags.get('sdk');
|
|
if (sdk !== 'claude' && sdk !== 'codex') {
|
|
throw new Error(`--sdk must be 'claude' or 'codex'; got '${sdk}'`);
|
|
}
|
|
const sdkTarget = flags.get('target');
|
|
if (!sdkTarget) {
|
|
throw new Error('--target=<sdkTarget> is required');
|
|
}
|
|
const outDir = flags.get('out') ?? path.resolve(process.cwd(), 'out');
|
|
return { sdk, sdkTarget, outDir };
|
|
}
|
|
|
|
if (isCliInvocation()) {
|
|
buildOne(parseCliArgs()).catch(err => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
// #endregion
|