diff --git a/.changeset/ovsx-namespace-and-publish-errors.md b/.changeset/ovsx-namespace-and-publish-errors.md new file mode 100644 index 00000000..ec1635e0 --- /dev/null +++ b/.changeset/ovsx-namespace-and-publish-errors.md @@ -0,0 +1,5 @@ +--- +"pythinker-code": patch +--- + +Publish the VS Code extension to Open VSX by creating the publisher namespace first, so Cursor, VSCodium and Windsurf can install it, and report the registry's own error when a publish fails instead of only the CLI exit line. diff --git a/apps/vscode/scripts/ovsx-publish.mjs b/apps/vscode/scripts/ovsx-publish.mjs index fcf476da..e8bddd64 100644 --- a/apps/vscode/scripts/ovsx-publish.mjs +++ b/apps/vscode/scripts/ovsx-publish.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { runLocalCli } from './local-cli.mjs'; import { parsePublishArguments, publishUsage } from './publish-args.mjs'; @@ -7,6 +8,36 @@ import { messageOf, publishEachTarget } from './publish-retry.mjs'; import { extensionRoot, isMainModule } from './vsix-targets.mjs'; import { verifyVsix } from './vsix-verify.mjs'; +/** + * Open VSX refuses every publish into a namespace that does not exist yet, and + * the Marketplace has no such concept — so a publisher that works there fails + * here on all six targets at once. Creating it is idempotent from our side: the + * namespace already existing is the success case, not an error. + * + * This is why 0.8.6 reached the Marketplace but no version ever reached Open VSX. + */ +export function ensureNamespace(namespace, run = runLocalCli) { + try { + run('ovsx', 'ovsx', ['create-namespace', namespace], { + cwd: extensionRoot, + encoding: 'utf8', + stdio: 'pipe', + }); + console.log(`Created Open VSX namespace ${namespace}.`); + } catch (error) { + if (/already exists|already owned/i.test(messageOf(error))) return; + throw error; + } +} + +function publisherName() { + const manifest = JSON.parse(readFileSync(join(extensionRoot, 'package.json'), 'utf8')); + if (typeof manifest.publisher !== 'string' || manifest.publisher === '') { + throw new Error('apps/vscode/package.json has no publisher to use as the Open VSX namespace.'); + } + return manifest.publisher; +} + async function main() { const options = parsePublishArguments(process.argv.slice(2)); if (options.help) { @@ -16,6 +47,7 @@ async function main() { if (!process.env.OVSX_PAT) throw new Error('OVSX_PAT is required to publish.'); await verifyInputs(options); + ensureNamespace(publisherName()); await publishEachTarget({ targets: options.targets, files: options.files, diff --git a/apps/vscode/scripts/publish-retry.mjs b/apps/vscode/scripts/publish-retry.mjs index 944d9af1..83b7131a 100644 --- a/apps/vscode/scripts/publish-retry.mjs +++ b/apps/vscode/scripts/publish-retry.mjs @@ -8,10 +8,33 @@ const AUTH_PATTERN = /\b401\b|unauthorized|invalidaccess|access denied|not allow export const DEFAULT_ATTEMPTS = 3; +/** Longest summary line worth printing; registry errors can be one huge JSON blob. */ +export const SUMMARY_LIMIT = 400; + export function messageOf(error) { return error instanceof Error ? error.message : String(error); } +/** + * One line for a summary, chosen so it carries the registry's own words. + * + * `runLocalCli` wraps a CLI failure as `Local ovsx exited with code 1:` followed + * by the captured output, so reporting only the first line printed six identical + * `FAILED : Local ovsx exited with code 1:` entries with the actual cause + * — a missing Open VSX namespace — cut off right after the colon. + */ +export function summaryLine(error) { + const lines = messageOf(error) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line !== ''); + if (lines.length === 0) return ''; + const [wrapper, ...rest] = lines; + // Cap the whole result, not just the joined branch: a registry that answers + // with one long JSON line would otherwise print unbounded. + return (rest.length === 0 ? wrapper : `${wrapper} ${rest.join(' ')}`).slice(0, SUMMARY_LIMIT); +} + /** * `auth` aborts the whole run — every remaining target would fail identically. * `transient` is worth retrying. `fatal` fails one target and lets the rest go. @@ -48,7 +71,7 @@ export async function withRetry(action, options = {}) { } const wait = backoffMs[Math.min(attempt - 1, backoffMs.length - 1)]; console.warn(`${label}: ${kind} failure on attempt ${attempt}/${attempts}, retrying in ${wait / 1000}s...`); - console.warn(` ${messageOf(error).split('\n')[0]}`); + console.warn(` ${summaryLine(error)}`); await delay(wait); } } @@ -79,7 +102,7 @@ export async function publishEachTarget({ targets, files, registry, publishOne } (outcome === 'skipped' ? skipped : published).push(target); } catch (error) { const kind = classifyError(error); - failures.push({ target, message: messageOf(error).split('\n')[0] }); + failures.push({ target, message: summaryLine(error) }); if (kind === 'auth') { abortReason = 'aborted after an authentication failure'; } diff --git a/apps/vscode/test/ovsx-namespace.test.ts b/apps/vscode/test/ovsx-namespace.test.ts new file mode 100644 index 00000000..9cdac8d4 --- /dev/null +++ b/apps/vscode/test/ovsx-namespace.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; + +// @ts-expect-error -- plain .mjs build script, no type declarations +import { ensureNamespace } from '../scripts/ovsx-publish.mjs'; + +/** + * Open VSX rejects a publish into a namespace that does not exist, which is why + * the 0.12.0 release put 0.8.6 on the Marketplace but left Open VSX with no + * version at all. The Marketplace has no namespace concept, so nothing upstream + * of this catches it. + */ +describe('ensureNamespace', () => { + it('creates the namespace before any publish is attempted', () => { + const run = vi.fn(); + + ensureNamespace('pythoughts', run); + + expect(run).toHaveBeenCalledTimes(1); + const [pkg, bin, args] = run.mock.calls[0] as [string, string, string[]]; + expect([pkg, bin]).toEqual(['ovsx', 'ovsx']); + expect(args).toEqual(['create-namespace', 'pythoughts']); + }); + + it('treats an existing namespace as success, so a re-run is safe', () => { + const run = vi.fn(() => { + throw new Error('Local ovsx exited with code 1:\nERROR Namespace already exists: pythoughts'); + }); + + expect(() => ensureNamespace('pythoughts', run)).not.toThrow(); + }); + + it('propagates a real failure instead of publishing into a broken namespace', () => { + const run = vi.fn(() => { + throw new Error('Local ovsx exited with code 1:\nERROR Response code 401 (Unauthorized)'); + }); + + expect(() => ensureNamespace('pythoughts', run)).toThrow(/401/u); + }); +}); diff --git a/apps/vscode/test/publish-retry.test.ts b/apps/vscode/test/publish-retry.test.ts index c34f65e6..27ffd84e 100644 --- a/apps/vscode/test/publish-retry.test.ts +++ b/apps/vscode/test/publish-retry.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; // @ts-expect-error -- plain .mjs build script, no type declarations -import { classifyError, publishEachTarget, withRetry } from '../scripts/publish-retry.mjs'; +import { classifyError, publishEachTarget, SUMMARY_LIMIT, summaryLine, withRetry } from '../scripts/publish-retry.mjs'; const TARGETS = ['darwin-x64', 'darwin-arm64', 'linux-x64']; const FILES = TARGETS.map((target) => `/tmp/${target}.vsix`); @@ -50,7 +50,63 @@ describe('withRetry', () => { }); }); +describe('summaryLine', () => { + /** + * The exact shape `runLocalCli` throws, and the exact reason the 0.12.0 release + * printed six `FAILED : Local ovsx exited with code 1:` lines with no + * cause: the summary kept only the wrapper line and dropped the output after it. + */ + it('keeps the registry error that follows the CLI wrapper line', () => { + const error = new Error( + 'Local ovsx exited with code 1:\nERROR Unknown namespace: pythoughts\n', + ); + + const line = summaryLine(error); + + expect(line).toContain('Unknown namespace: pythoughts'); + expect(line).toContain('exited with code 1'); + }); + + it('caps a long error whether or not it has a second line', () => { + // The cap used to bind only to the joined branch, so a registry answering + // with one long JSON line printed in full. + expect(summaryLine(new Error('x'.repeat(500)))).toHaveLength(SUMMARY_LIMIT); + expect(summaryLine(new Error(`wrapper:\n${'y'.repeat(500)}`))).toHaveLength(SUMMARY_LIMIT); + }); + + it('leaves a single-line error alone and survives a blank one', () => { + expect(summaryLine(new Error('Response code 401 (Unauthorized)'))) + .toBe('Response code 401 (Unauthorized)'); + // A CLI that failed without writing anything: every line is blank. + expect(summaryLine(' \n \n')).toBe(''); + }); +}); + describe('publishEachTarget', () => { + it('reports the underlying cause for a failed target, not just the wrapper', async () => { + const publishOne = vi.fn().mockRejectedValue( + new Error('Local ovsx exited with code 1:\nERROR Unknown namespace: pythoughts'), + ); + const logged: string[] = []; + const log = vi.spyOn(console, 'log').mockImplementation((...args) => { + logged.push(args.join(' ')); + }); + + try { + await expect( + publishEachTarget({ targets: TARGETS, files: FILES, registry: 'Open VSX', publishOne }), + ).rejects.toThrow('3 of 3 target(s) failed'); + } finally { + log.mockRestore(); + } + + const failures = logged.filter((line) => line.includes('FAILED')); + expect(failures).toHaveLength(3); + for (const failure of failures) { + expect(failure).toContain('Unknown namespace: pythoughts'); + } + }); + it('keeps publishing after one target fails, so a flake cannot strand the rest', async () => { const publishOne = vi.fn(async (_file: string, target: string) => { if (target === 'darwin-arm64') throw new Error('Extension rejected');