Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/ovsx-namespace-and-publish-errors.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 33 additions & 1 deletion apps/vscode/scripts/ovsx-publish.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
#!/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';
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) {
Expand All @@ -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,
Expand Down
27 changes: 25 additions & 2 deletions apps/vscode/scripts/publish-retry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <target>: 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.
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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';
}
Expand Down
39 changes: 39 additions & 0 deletions apps/vscode/test/ovsx-namespace.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
58 changes: 57 additions & 1 deletion apps/vscode/test/publish-retry.test.ts
Original file line number Diff line number Diff line change
@@ -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`);
Expand Down Expand Up @@ -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 <target>: 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');
Expand Down
Loading