Files
cline/apps/vscode/scripts/publish-nightly.mjs
T
dcf78364ca fix(vscode): reliable MCP OAuth on the SDK extension (ENG-2108, CLINE-2304) (#11529)
* fix(vscode): store MCP OAuth in shared settings file like the CLI (ENG-2108)

VSCode stored MCP OAuth tokens in a single mcpOAuthSecrets secrets blob
keyed by sha256(name:url), while the CLI/SDK store per-server oauth state in
cline_mcp_settings.json. The two never interoperated (CLI auth was invisible to
VSCode), and VSCode's read-whole-blob/write-whole-blob through StateManager's
non-refreshing cache meant concurrent windows clobbered each other's tokens.

- Store MCP OAuth state in the shared settings file in @cline/core's format.
- Reads are fresh from disk; writes are scoped read-modify-write of one
  server's oauth key via updateMcpServerOAuthState (now atomic temp+rename).
- Replace the vscode:// callback flow with HTTP-based token collection via
  authorizeMcpServerOAuth (same local loopback flow the CLI uses).
- Reconnect an unauthenticated server when its tokens appear (e.g. CLI auth).
- One-time migration of legacy mcpOAuthSecrets tokens into the shared file.
- Remove McpOAuthRedirectResolver, mcpOAuthFlow, completeOAuth, and the
  mcp-auth URI callback route.

* feat(vscode): add --instances/--random-port to MCP OAuth test server

Lets you start several independent test servers, each on its own OS-assigned
random port, so you can add multiple streamableHttp MCP servers to Cline at
once and exercise concurrent OAuth flows. baseUrl now reflects the actually
bound port so discovery metadata and redirect URIs stay correct under random
ports.

* fix(vscode): stop MCP OAuth handshake writes from livelocking the settings watcher (ENG-2108)

Now that codeVerifier/clientInformation live in the shared settings file, the
MCP SDK's per-connect-attempt saveCodeVerifier() writes were tripping the
settings watcher, which re-entered updateServerConnections -> connectToServer
-> another write, looping forever. It was especially bad with two+
unauthenticated servers, where each server's verifier churn re-triggered the
other (visible as a flickering, ever-changing codeVerifier nonce).

The watcher now compares a connection-relevant fingerprint (full per-server
config minus the oauth block, plus a boolean for whether an access token
exists) and skips writes that only churn OAuth-handshake fields. A token
appearing/disappearing still changes the fingerprint, so CLI/other-window
authorization continues to trigger a reconnect via serverGainedOAuthTokens.

* feat(vscode): print paste-ready MCP settings fragment from OAuth test server

On startup the test server now emits an mcpServers JSON fragment (nested
transport shape, matching cline_mcp_settings.json) alongside the banner, so you
can paste it straight into the settings file instead of hand-writing it. With
--instances the entries get distinct names (oauth-test-1, ...), each carrying
its actual bound port.

* fix(vscode): atomic MCP settings writes + fingerprint gate; drop timer guards (CLINE-2097)

Deleting one MCP server could empty the whole list. Root cause: settings
writes were non-atomic (fs.writeFile), so chokidar (and any other process)
could read a transient empty/torn file mid-write and reconcile to zero servers.
The previous fix only masked this with a per-process isUpdatingClineSettings
boolean cleared on a 300ms timer — it did nothing for the CLI or other windows
and was racy.

Replace both timer guards (isUpdatingClineSettings, isUpdatingFromRemoteConfig)
with two deterministic, process-agnostic mechanisms:

- writeSettingsFile(): atomic temp-file + rename for every settings write, so
  any reader always sees a complete file. Holds for any number of concurrent
  writers (CLI, multiple windows, SDK OAuth handshake).
- content fingerprint: the watcher reconciles only when the connection-relevant
  view changed. writeSettingsFile pre-seeds the fingerprint so our own write is
  a no-op, while a genuine change from any other process is still processed.
  Because reconcile is idempotent and reads are never torn, a missed
  suppression is at worst a redundant reconnect, never data loss.

All RPC writers (toggle disabled, autoApprove x2, timeout, add, delete) and the
remote-config sync now go through writeSettingsFile. Removes all setTimeout(.,
300) flag juggling.

* feat(vscode): add a non-guessable 'frozzle' tool to the MCP OAuth test server

The MCP OAuth test server now serves tools/list + tools/call exposing a
'frozzle' tool whose output cannot be derived without calling it (reverse the
string and swap each letter's case, wrapped in guillemets). This gives an eval
a reliable end-to-end signal that the OAuth-authenticated MCP round-trip really
happened: a correct 'frozzle <text>' answer can't be hallucinated. The
transform is easy to verify at a glance and invertible. Adds frozzle.test.ts.

* fix(sdk): drop lingering OAuth callback sockets on close so deny->approve re-auth works (ENG-2108)

The local OAuth callback server's close() called Server.close(), which only
stops accepting new connections and lets existing keep-alive sockets linger.
The browser / global-fetch connection pool keeps such a socket to the fixed
callback port (1456) alive. So after the user denied an MCP OAuth request and
retried, the retry's approve callback could be delivered over the pooled socket
to the FIRST (already-settled) server. That server's settle() was a no-op, so
waitForCallback() never resolved, finishAuth()/token exchange never ran, and no
token was saved — the server stayed unauthenticated (the deny->approve repro).

Call server.closeAllConnections() in close() so no pooled socket outlives the
server. Adds a regression test driving a keep-alive agent across close().

* fix(vscode): actually reconnect MCP server when toggled back on (ENG-2108)

toggleServerDisabledRPC only flipped the in-memory disabled flag and set status
to 'connecting', but never rebuilt the connection. A disabled server's
connection has no live transport/client, so re-enabling left it stuck on the
yellow 'connecting' indicator forever and never re-advertised its tools to the
agent.

Tear down and rebuild the connection through deleteConnection + connectToServer
(which opens a real transport when enabled, or a disconnected stub when
disabled), then notifyWebviewOfServerChanges so the SDK session's tool list is
refreshed. OAuth state is preserved (deleteConnection doesn't clear it). Adds
McpHub.toggleServerDisabledRPC.test.ts.

* fix(vscode): reload MCP tools silently without chat spam (ENG-2108)

Restarting the SDK session to pick up MCP tool changes appended visible chat
messages ('MCP tools changed - reloading...' and 'MCP tools reloaded
successfully...') plus a completion_result banner. Toggling several servers
piled up many of these. Tool reloading should be transparent.

Emit only the session status transitions (running -> idle) via
emitSessionEvents([], ...) instead of appendAndEmit, so no chat messages or
completion banner are shown. Genuine reload failures still surface an error
message. Updates sdk-mcp-coordinator.test.ts accordingly.

* docs(mcp): clean up comments to describe current behavior

Revise comments across the MCP OAuth and settings code to document the code as
it stands, dropping references to prior implementations, task IDs, and
before/after narration. Also reflow the auth-server regression test to the
repository's formatter. No behavior change.

* fix(vscode): atomic fallback write in remote MCP sync; document sync OAuth I/O

Make the no-McpHub branch of syncRemoteMcpServersToSettings write via an
atomic temp-file + rename so a concurrent reader never observes a torn or
empty settings file, matching every other settings write.

Document why the OAuth state read-modify-write in McpOAuthManager is
synchronous: it serializes this process's shared-file updates without a
Promise queue, which we prefer over async I/O for reliability of the
cross-process settings file.

* fix(mcp): serialize settings read-modify-writes

* docs(vscode): clarify MCP settings create race

* fix(vscode): create MCP settings atomically

* fix(cli): keep clearing missing MCP OAuth state a no-op

* fix(vscode): avoid yielding while holding MCP settings lock (#11596)

* fix(mcp): async lock acquisition for VSCode MCP settings/OAuth writes

Add updateMcpSettingsFile/updateMcpServerOAuthStateAsync to @cline/core that
yield the event loop while acquiring the cross-process settings lock instead of
blocking it with Atomics.wait. The critical section stays synchronous and the
mutator stays pure, so the lock is never held across an await and serialization
is preserved without an in-process queue.

Route the VSCode extension host's OAuth state writes (McpOAuthManager) through
the async variant so a connection-time OAuth callback can no longer freeze the
extension host event loop or deadlock against an in-flight updateMcpSettingsFile
whose lock-releasing continuation needs the loop.

Unify the sync and async acquisition paths on a shared reentrancy guard
(activeLocks) so a nested settings update on the same file fails fast instead of
self-deadlocking.

Tests: contended async serialization asserting zero Atomics.wait calls, async
stale-lock reclaim, reentrancy fail-fast, and uncontended run+release.

* fix(mcp): bootstrap missing settings file inside the lock; tidy docs

Creating the MCP settings file now happens in one place: the locked
read-modify-write helpers. A missing file reads as an empty settings object, so
the first write to a fresh path (e.g. a fresh-install `cline mcp add`) creates
it inside the lock instead of throwing ENOENT. The SDK (updateMcpSettingsFile /
updateMcpSettingsFileSync) and the VSCode lock helper share this contract, so
callers no longer need to pre-create the file. Add regression tests for the
SDK, the CLI wizard addServer(), and the VSCode helper on a missing path.

Also flag the synchronous SDK entry points (updateMcpSettingsFileSync,
updateMcpServerOAuthState) as preferring their async siblings, with a TODO to
delete them once all callers migrate, and tighten the lock-helper doc comments
to describe current behavior.

* fix(vscode): finish npm->bun migration in dev tooling, tasks, and docs

The npm->bun migration (#11632) updated package scripts, .vscodeignore and .vscode-test.mjs but left a trail of npm/npx/node invocations in editor configs, dev scripts, and docs. Following the breadcrumbs from 'npm run protos':

- .vscode/launch.json: standalone-core debug uses 'bun <file>.ts' (was npx tsx); Open Storybook uses 'bun run' (was npm run).
- .vscode/tasks.json: all task commands use 'bun run' (was npm run).
- scripts/run-extension-host.sh and .claude/hooks/claude-code-for-web-setup.sh: 'bun run' (was npm run).
- debug-harness/server.ts: shebang 'bun'; build steps use 'bun run protos', 'bun esbuild.mjs', 'bunx vite build' (were npm/node/npx).
- dev script shebangs (test-hostbridge-server, test-standalone-core-api-server, testing-platform-orchestrator, interactive-playwright): '#!/usr/bin/env bun' (was npx tsx).
- WebviewProvider HMR hint, e2e README, copilot-instructions, PR template, mcp-oauth-test-server docs, generate-state-proto message, tsconfig.test comment, state-keys test comment: bun.

Left untouched (correct per .clinerules/bun-and-node): Node-runtime invocations (node build.mjs), prebuild-install --target=<node>, vsce, 'npm install -g cline' (user CLI install), and App.stories.tsx mock chat fixtures.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-06-18 22:27:04 -04:00

612 lines
18 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Nightly publish script for VS Code extension
* Converts package.json to testing version, packages, publishes, and restores
*
* This script:
* 1. Backs up the original package.json
* 2. Updates package.json with:
* - New version (major.minor.timestamp format)
* - Changes name to "cline-nightly"
* - Changes displayName to "Cline (Nightly)"
* 3. Packages the extension as a .vsix file
* 4. Publishes to VS Code Marketplace (if VSCE_PAT is set)
* 5. Publishes to OpenVSX Registry (if OVSX_PAT is set)
* 6. Restores the original package.json
*
* Channels:
* By default, the extension is published to the RELEASE channel of
* `cline-nightly` (this is what the scheduled daily nightly workflow
* uses). Pass --pre-release to instead publish to the pre-release
* channel of `cline-nightly` (used for manual publishes from feature
* branches that need tester opt-in via "Switch to Pre-Release Version").
*
* Note on version ordering: because VS Code serves pre-release users
* whichever version is highest across *both* channels, the pre-release
* build only stays selected while its version number is greater than
* the latest release nightly. Since both channels use
* `major.minor.<unix-timestamp>`, the most recently published build
* wins. When this script is used for a manual pre-release publish, the
* scheduled release nightly workflow will eventually publish a newer
* timestamp and pull pre-release users forward onto release — which is
* the desired behavior once an experimental branch is abandoned, but
* means ongoing previews require re-publishing from the branch at
* least as often as the scheduled release nightly runs.
*
* Usage:
* bun run publish:marketplace:nightly # release channel
* bun run publish:marketplace:nightly -- --pre-release # pre-release channel
* bun run publish:marketplace:nightly -- --dry-run # package only
*
* Environment variables:
* VSCE_PAT - Personal Access Token for VS Code Marketplace
* OVSX_PAT - Personal Access Token for OpenVSX Registry
*
* Dependencies:
* - vsce (VS Code Extension Manager)
* - ovsx (OpenVSX CLI)
*/
import { execFileSync, execSync } from "node:child_process"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { restore as restoreMarketplaceReadme, swapIn as swapInMarketplaceReadme } from "./marketplace-readme.mjs"
// Get __dirname equivalent in ES modules
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// ANSI color codes for console output
const colors = {
reset: "\x1b[0m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
}
// Logging utilities
const log = {
info: (msg) => console.log(`${colors.green}[INFO]${colors.reset} ${msg}`),
warn: (msg) => console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`),
error: (msg) => console.error(`${colors.red}[ERROR]${colors.reset} ${msg}`),
}
// Configuration
const config = {
// The name and display name for the nightly version
nightlyName: "cline-nightly",
originalName: "claude-dev",
nightlyDisplayName: "Cline (Nightly)",
projectRoot: path.join(__dirname, ".."),
get packageJsonPath() {
return path.join(this.projectRoot, "package.json")
},
get packageBackupPath() {
return path.join(this.projectRoot, "package.json.backup")
},
get distDir() {
return path.join(this.projectRoot, "dist")
},
get vsixPath() {
return path.join(this.distDir, "cline-nightly.vsix")
},
get nodeModulesPath() {
return path.join(this.projectRoot, "node_modules")
},
get originalWorkspaceLinkPath() {
return path.join(this.nodeModulesPath, this.originalName)
},
get nightlyWorkspaceLinkPath() {
return path.join(this.nodeModulesPath, this.nightlyName)
},
}
// Utility class for managing the publish process
class NightlyPublisher {
constructor() {
this.originalPackageJson = null
this.hasBackup = false
this.didRenameWorkspaceLink = false
this.didCreateNightlyWorkspaceLink = false
this.didSwapMarketplaceReadme = false
}
/**
* Resolve symlink target to an absolute path.
*/
resolveSymlinkTarget(linkPath) {
const target = fs.readlinkSync(linkPath)
return path.resolve(path.dirname(linkPath), target)
}
/**
* Validate that a path is the expected workspace self-link to project root.
*/
isExpectedWorkspaceSelfLink(linkPath) {
try {
if (!fs.lstatSync(linkPath).isSymbolicLink()) {
return false
}
return this.resolveSymlinkTarget(linkPath) === path.resolve(config.projectRoot)
} catch {
return false
}
}
/**
* Check if required dependencies are installed
*/
checkDependencies() {
const dependencies = [
{ name: "vsce", check: "vsce --version" },
{ name: "npx", check: "npx --version" },
]
const missing = []
for (const dep of dependencies) {
try {
execSync(dep.check, { stdio: "ignore" })
} catch {
missing.push(dep.name)
}
}
if (missing.length > 0) {
throw new Error(
`Missing required dependencies: ${missing.join(", ")}. Please install them before running this script.`,
)
}
log.info("All dependencies are installed")
}
/**
* Check if a command exists
*/
commandExists(command) {
try {
execSync(`which ${command}`, { stdio: "ignore" })
return true
} catch {
return false
}
}
/**
* Create backup of package.json
*/
backupPackageJson() {
if (!fs.existsSync(config.packageJsonPath)) {
throw new Error(`package.json not found at ${config.packageJsonPath}`)
}
log.info("Backing up original package.json")
this.originalPackageJson = fs.readFileSync(config.packageJsonPath, "utf-8")
fs.writeFileSync(config.packageBackupPath, this.originalPackageJson)
this.hasBackup = true
}
/**
* Restore original package.json
*/
restorePackageJson() {
if (this.hasBackup && fs.existsSync(config.packageBackupPath)) {
log.info("Restoring original package.json")
fs.writeFileSync(config.packageJsonPath, this.originalPackageJson)
fs.unlinkSync(config.packageBackupPath)
this.hasBackup = false
}
}
/**
* Keep workspace self-link consistent with package name during nightly packaging.
*
* The repo root is a workspace package ("."). When npm installs dependencies,
* it creates a self-link at node_modules/<package-name>. Nightly packaging
* changes package.json name from "claude-dev" to "cline-nightly". If we don't
* align this link, vsce's dependency detection (`npm list --production`) fails
* with ELSPROBLEMS (missing cline-nightly + extraneous claude-dev).
*/
reconcileWorkspaceSelfLinkForNightly() {
const originalPath = config.originalWorkspaceLinkPath
const nightlyPath = config.nightlyWorkspaceLinkPath
if (!fs.existsSync(config.nodeModulesPath)) {
log.warn("node_modules not found, skipping workspace self-link reconciliation")
return
}
if (fs.existsSync(nightlyPath)) {
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
throw new Error(
`Refusing to continue: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
)
}
log.info("Nightly workspace self-link already exists")
return
}
if (fs.existsSync(originalPath)) {
if (!this.isExpectedWorkspaceSelfLink(originalPath)) {
throw new Error(
`Refusing to continue: unexpected path at ${originalPath}. Expected a workspace symlink to ${config.projectRoot}`,
)
}
log.info(`Renaming workspace self-link: ${config.originalName} -> ${config.nightlyName}`)
fs.renameSync(originalPath, nightlyPath)
this.didRenameWorkspaceLink = true
return
}
// In some environments npm may not have created the workspace self-link yet.
// Create it explicitly so `npm list --production` can resolve the renamed
// package name during vsce dependency detection.
log.warn("Original workspace self-link not found, creating nightly workspace self-link")
fs.symlinkSync(config.projectRoot, nightlyPath, "dir")
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
throw new Error(`Failed to create expected workspace symlink at ${nightlyPath}`)
}
this.didCreateNightlyWorkspaceLink = true
}
/**
* Restore workspace self-link after packaging.
*/
restoreWorkspaceSelfLink() {
if (!this.didRenameWorkspaceLink && !this.didCreateNightlyWorkspaceLink) {
return
}
const originalPath = config.originalWorkspaceLinkPath
const nightlyPath = config.nightlyWorkspaceLinkPath
if (fs.existsSync(nightlyPath) && !fs.existsSync(originalPath)) {
if (this.didRenameWorkspaceLink) {
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
throw new Error(
`Refusing to restore: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
)
}
log.info(`Restoring workspace self-link: ${config.nightlyName} -> ${config.originalName}`)
fs.renameSync(nightlyPath, originalPath)
} else if (this.didCreateNightlyWorkspaceLink) {
if (!this.isExpectedWorkspaceSelfLink(nightlyPath)) {
throw new Error(
`Refusing to remove: unexpected path at ${nightlyPath}. Expected a workspace symlink to ${config.projectRoot}`,
)
}
log.info(`Removing temporary workspace self-link: ${config.nightlyName}`)
fs.unlinkSync(nightlyPath)
}
}
this.didRenameWorkspaceLink = false
this.didCreateNightlyWorkspaceLink = false
}
/**
* Swap README.marketplace.md into README.md so the .vsix is packaged with
* the marketplace-flavored README. vsce reads README.md from disk at
* `vsce package` time and there's no flag to redirect it.
*/
swapMarketplaceReadme() {
const result = swapInMarketplaceReadme()
this.didSwapMarketplaceReadme = !result.skipped
if (this.didSwapMarketplaceReadme) {
log.info("Swapped README.marketplace.md into README.md for packaging")
}
}
/**
* Restore README.md if this publisher performed the swap.
*/
restoreMarketplaceReadme() {
if (!this.didSwapMarketplaceReadme) {
return
}
try {
restoreMarketplaceReadme()
log.info("Restored original README.md")
} catch (error) {
log.error(`Failed to restore README.md: ${error.message}`)
}
this.didSwapMarketplaceReadme = false
}
/**
* Generate new version with timestamp
* Format: major.minor.timestamp
*/
generateVersion(currentVersion) {
// Extract major.minor from current version (e.g., "3.27.1" -> "3.27")
const versionParts = currentVersion.split(".")
if (versionParts.length < 2) {
throw new Error(`Invalid version format: ${currentVersion}`)
}
const major = versionParts[0]
const minor = versionParts[1]
const timestamp = Math.floor(Date.now() / 1000)
return `${major}.${minor}.${timestamp}`
}
/**
* Update package.json with nightly configuration
*/
updatePackageJson() {
// Replace any occurrences cline. or claude-dev with nightly name
const rawContent = fs.readFileSync(config.packageJsonPath, "utf-8")
const content = rawContent.replaceAll("claude-dev", config.nightlyName).replaceAll('"cline.', `"${config.nightlyName}.`)
const pkg = JSON.parse(content)
const currentVersion = pkg.version
if (!currentVersion) {
throw new Error("Could not read version from package.json")
}
log.info(`Current version: ${currentVersion}`)
const newVersion = this.generateVersion(currentVersion)
log.info(`New version: ${newVersion}`)
// Update package.json fields
pkg.version = newVersion
pkg.name = config.nightlyName
pkg.displayName = config.nightlyDisplayName
pkg.contributes.viewsContainers.activitybar.title = config.nightlyDisplayName
// Save updated package.json
log.info("Updating package.json for nightly build")
fs.writeFileSync(config.packageJsonPath, JSON.stringify(pkg, null, "\t"))
return newVersion
}
/**
* Package the extension
*/
packageExtension(isPreRelease = false) {
// Ensure dist directory exists
if (!fs.existsSync(config.distDir)) {
fs.mkdirSync(config.distDir, { recursive: true })
}
log.info(`Packaging extension${isPreRelease ? " (pre-release)" : ""}`)
const args = [
"package",
...(isPreRelease ? ["--pre-release"] : []),
// The extension is fully esbuild-bundled, so vsce must not walk node_modules
// (the @cline/* workspace symlinks point outside the package).
"--no-dependencies",
"--no-update-package-json",
"--no-git-tag-version",
"--allow-package-secrets",
"sendgrid",
"--out",
config.vsixPath,
]
try {
execFileSync("vsce", args, {
stdio: "inherit",
cwd: config.projectRoot,
})
log.info(`Package created: ${config.vsixPath}`)
} catch (error) {
throw new Error(`Failed to package extension: ${error.message}`)
}
}
/**
* Publish to VS Code Marketplace
*/
publishToVSCodeMarketplace(isPreRelease = false) {
const token = process.env.VSCE_PAT
if (!token) {
log.warn("VSCE_PAT not set, skipping VS Code Marketplace publish")
return false
}
log.info(`Publishing to VS Code Marketplace${isPreRelease ? " (pre-release channel)" : ""}`)
const args = [
"publish",
...(isPreRelease ? ["--pre-release"] : []),
"--no-git-tag-version",
"--packagePath",
config.vsixPath,
]
try {
execFileSync("vsce", args, {
env: { ...process.env, VSCE_PAT: token },
stdio: "inherit",
cwd: config.projectRoot,
})
log.info("Successfully published to VS Code Marketplace")
return true
} catch (error) {
throw new Error(`Failed to publish to VS Code Marketplace: ${error.message}`)
}
}
/**
* Publish to OpenVSX Registry
*/
publishToOpenVSX(isPreRelease = false) {
const token = process.env.OVSX_PAT
if (!token) {
log.warn("OVSX_PAT not set, skipping OpenVSX Registry publish")
return false
}
log.info(`Publishing to OpenVSX Registry${isPreRelease ? " (pre-release channel)" : ""}`)
const args = [
"ovsx",
"publish",
...(isPreRelease ? ["--pre-release"] : []),
"--packagePath",
config.vsixPath,
"--pat",
token,
]
try {
execFileSync("npx", args, {
stdio: "inherit",
cwd: config.projectRoot,
})
log.info("Successfully published to OpenVSX Registry")
return true
} catch (error) {
throw new Error(`Failed to publish to OpenVSX Registry: ${error.message}`)
}
}
/**
* Main execution flow
*/
async run({ isDryRun = false, isPreRelease = false } = {}) {
try {
const channelLabel = isPreRelease ? " (pre-release channel)" : " (release channel)"
log.info(`Starting nightly publish process${channelLabel}${isDryRun ? " (dry run)" : ""}`)
// Step 1: Check dependencies
this.checkDependencies()
// Step 2: Backup package.json
this.backupPackageJson()
// Step 3: Update package.json
const newVersion = this.updatePackageJson()
// Step 3.5: Keep npm workspace self-link aligned with nightly package name
this.reconcileWorkspaceSelfLinkForNightly()
// Step 3.6: Swap in marketplace README before packaging
this.swapMarketplaceReadme()
// Step 4: Package extension
this.packageExtension(isPreRelease)
// Step 5: Publish to marketplaces (skip if dry run)
let vsCodePublished = false
let openVSXPublished = false
if (isDryRun) {
log.info("Dry run mode: Skipping marketplace publishing")
} else {
vsCodePublished = this.publishToVSCodeMarketplace(isPreRelease)
openVSXPublished = this.publishToOpenVSX(isPreRelease)
}
// Summary
log.info(`Nightly publish process completed successfully${isDryRun ? " (dry run)" : ""}`)
log.info(`Package created for v${newVersion}: ${config.vsixPath}`)
if (!isDryRun && !vsCodePublished && !openVSXPublished) {
log.warn("Extension was packaged but not published to any marketplace")
log.warn("Set VSCE_PAT and/or OVSX_PAT environment variables to enable publishing")
}
} catch (error) {
log.error(`Publish failed: ${error.message}`)
process.exit(1)
} finally {
// Always restore workspace link first
this.restoreWorkspaceSelfLink()
// Always restore package.json
this.restorePackageJson()
// Always restore README.md
this.restoreMarketplaceReadme()
}
}
}
// Handle cleanup on process exit
const publisher = new NightlyPublisher()
process.on("exit", () => {
publisher.restoreWorkspaceSelfLink()
publisher.restorePackageJson()
publisher.restoreMarketplaceReadme()
})
process.on("SIGINT", () => {
log.info("\nInterrupted, cleaning up...")
publisher.restoreWorkspaceSelfLink()
publisher.restorePackageJson()
publisher.restoreMarketplaceReadme()
process.exit(130)
})
process.on("SIGTERM", () => {
log.info("\nTerminated, cleaning up...")
publisher.restoreWorkspaceSelfLink()
publisher.restorePackageJson()
publisher.restoreMarketplaceReadme()
process.exit(143)
})
// Parse command line arguments
const args = process.argv.slice(2)
const isDryRun = args.includes("--dry-run") || args.includes("-n")
const isPreRelease = args.includes("--pre-release")
const knownFlags = ["--dry-run", "-n", "--pre-release", "--help", "-h"]
const unknownArgs = args.filter((a) => !knownFlags.includes(a))
if (unknownArgs.length > 0) {
log.error(`Unknown argument(s): ${unknownArgs.join(", ")}. Run with --help for usage.`)
process.exit(1)
}
const showHelp = args.includes("--help") || args.includes("-h")
if (showHelp) {
console.log(`
Nightly publish script for VS Code extension
Usage:
bun run publish:marketplace:nightly [options]
Options:
--pre-release Publish to the pre-release channel of cline-nightly.
Default is the release channel (used by the scheduled
nightly workflow).
--dry-run, -n Run without actually publishing (package only)
--help, -h Show this help message
Environment variables:
VSCE_PAT Personal Access Token for VS Code Marketplace
OVSX_PAT Personal Access Token for OpenVSX Registry
Examples:
bun run publish:marketplace:nightly # Release channel publish
bun run publish:marketplace:nightly -- --pre-release # Pre-release channel publish
bun run publish:marketplace:nightly -- --dry-run # Package only
VSCE_PAT="token" bun run publish:marketplace:nightly # Publish to VS Code only
`)
process.exit(0)
}
// Run the publisher
publisher.run({ isDryRun, isPreRelease }).catch((error) => {
log.error(error.message)
process.exit(1)
})