From 88222ec99e03bfe7aa33380b7832916b40b06bad Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 18:22:49 -0400 Subject: [PATCH 1/2] fix(vscode): create the Open VSX namespace before publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.12.0 release put 0.8.6 on the Visual Studio Marketplace but left Open VSX with no version at all: all six targets failed because Open VSX rejects a publish into a namespace that does not exist, and the Marketplace has no such concept so nothing upstream catches it. The publish now creates the namespace first and treats an existing one as success, so a re-run is safe. The cause was invisible in the logs. runLocalCli captures the CLI's output, but the per-target summary kept only the first line — the 'Local ovsx exited with code 1:' wrapper — and dropped the registry error after it. Six identical failures reported no reason at all. The summary now carries the registry's own words. --- .../ovsx-namespace-and-publish-errors.md | 5 ++ apps/vscode/scripts/ovsx-publish.mjs | 34 ++++++++++++- apps/vscode/scripts/publish-retry.mjs | 22 +++++++- apps/vscode/test/ovsx-namespace.test.ts | 39 ++++++++++++++ apps/vscode/test/publish-retry.test.ts | 51 ++++++++++++++++++- 5 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 .changeset/ovsx-namespace-and-publish-errors.md create mode 100644 apps/vscode/test/ovsx-namespace.test.ts 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..5379b36a 100644 --- a/apps/vscode/scripts/publish-retry.mjs +++ b/apps/vscode/scripts/publish-retry.mjs @@ -12,6 +12,24 @@ 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; + return rest.length === 0 ? wrapper : `${wrapper} ${rest.join(' ')}`.slice(0, 400); +} + /** * `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 +66,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 +97,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..29ee51d0 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, 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,56 @@ 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('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'); From 329128a592f464fa3958e2f164a1ba4ebf0345c3 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 18:36:32 -0400 Subject: [PATCH 2/2] fix(vscode): cap every summary line, not just the multi-line branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .slice(0, 400) bound to the template literal rather than the conditional, so a single-line error came back uncapped — a registry answering with one long JSON line would print unbounded in both the retry warning and the target summary. Verified before the fix: a 500-character single-line error returned 500, while the multi-line path already capped at 400. The limit is now a named constant so the test asserts against it. --- apps/vscode/scripts/publish-retry.mjs | 7 ++++++- apps/vscode/test/publish-retry.test.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/vscode/scripts/publish-retry.mjs b/apps/vscode/scripts/publish-retry.mjs index 5379b36a..83b7131a 100644 --- a/apps/vscode/scripts/publish-retry.mjs +++ b/apps/vscode/scripts/publish-retry.mjs @@ -8,6 +8,9 @@ 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); } @@ -27,7 +30,9 @@ export function summaryLine(error) { .filter((line) => line !== ''); if (lines.length === 0) return ''; const [wrapper, ...rest] = lines; - return rest.length === 0 ? wrapper : `${wrapper} ${rest.join(' ')}`.slice(0, 400); + // 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); } /** diff --git a/apps/vscode/test/publish-retry.test.ts b/apps/vscode/test/publish-retry.test.ts index 29ee51d0..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, summaryLine, 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`); @@ -67,6 +67,13 @@ describe('summaryLine', () => { 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)');