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
14 changes: 7 additions & 7 deletions specter/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -78,19 +78,19 @@ install-hooks:
# Sync VERSION into the VS Code extension's package.json
version-sync:
@VER=$$(cat VERSION); \
node -e "const fs=require('fs'),p='vscode-extension/package.json',j=JSON.parse(fs.readFileSync(p));j.version='$$VER';fs.writeFileSync(p,JSON.stringify(j,null,2)+'\n');" && \
echo "Synced version $$VER → vscode-extension/package.json"
node -e "const fs=require('fs'),p='vscode-extension/package.json',j=JSON.parse(fs.readFileSync(p));j.specterCli=j.specterCli||{};j.specterCli.default='$$VER';fs.writeFileSync(p,JSON.stringify(j,null,2)+'\n');" && \
echo "Synced CLI default $$VER -> vscode-extension/package.json specterCli.default (the extension's own version is independent)"

# Publish VS Code extension to Marketplace
# Usage: make publish-vscode PAT=<your-token>
# Usage: VSCE_PAT=... make publish-vscode (token from the environment only)
publish-vscode:
@if [ -z "$(PAT)" ]; then \
echo "Usage: make publish-vscode PAT=<your-personal-access-token>"; \
@if [ -z "$$VSCE_PAT" ]; then \
echo "Set VSCE_PAT in the environment. The token is never passed on the command line."; \
exit 1; \
fi
$(MAKE) version-sync
cd vscode-extension && npm run build
cd vscode-extension && npx vsce publish --pat $(PAT)
cd vscode-extension && npx vsce publish

clean:
rm -rf bin/ specter-linux-* specter-darwin-* specter-windows-*
Expand Down Expand Up @@ -150,7 +150,7 @@ prerelease: check test-race vulncheck dogfood
# checklist. Does NOT run vsce publish under any circumstance.
# See CLAUDE.md > "Release discipline" for why every step matters.
release-check: prerelease
@VER=$$(cat VERSION); \
@VER=$$(node -e "console.log(require('./vscode-extension/package.json').version)"); \
VSIX=vscode-extension/specter-vscode-$$VER.vsix; \
if [ ! -f "$$VSIX" ]; then \
echo "ERROR: expected $$VSIX after prerelease; not found."; exit 1; \
Expand Down
26 changes: 23 additions & 3 deletions specter/specs/spec-vscode.spec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
spec:
id: spec-vscode
version: "6.0.0"
version: "7.0.0"
status: draft
tier: 2

Expand Down Expand Up @@ -201,7 +201,7 @@ spec:
enforcement: error

- id: C-27
description: "The extension's private copy (C-34) MUST default to the CLI version matching the extension's own version — a v0.10.0 extension fetches v0.10.0 CLI, not whatever GitHub's /releases/latest currently returns. The extension reads its own version via ctx.extension.packageJSON.version at download time. Users MAY override via specter.version: set to 'latest' to track GitHub's newest release, or pin a specific semver (e.g. '0.9.2'). Default-to-latest is prohibited because the CLI release and extension Marketplace publish are decoupled — a GoReleaser-produced CLI release fires on tag push, but the matching extension may publish days later (or not at all), creating split-brain installs where users run an older extension against a newer CLI. The private copy is how the extension guarantees itself a compatible CLI. It is not how the user's shell gets one: a newer CLI on PATH is used as is while it satisfies the C-34 range, and is never replaced."
description: "The extension's private copy (C-34) MUST default to the CLI version declared in package.json under `specterCli.default`, not to the extension's own version and not to whatever GitHub's /releases/latest currently returns. The declared default MUST equal the CLI version the repository ships, its VERSION file, and MUST satisfy `specterCli.range`; a test binds both. The extension's own version number is free to move without a CLI release, which is what lets an extension-only fix reach the Marketplace stable channel when the same number is already published as pre-release, since the Marketplace never accepts a version number twice. Users MAY override via specter.version: set to 'latest' to track GitHub's newest release, or pin a specific semver (e.g. '0.9.2'). Default-to-latest is prohibited because the CLI release and extension Marketplace publish are decoupled — a GoReleaser-produced CLI release fires on tag push, but the matching extension may publish days later (or not at all), creating split-brain installs where users run an older extension against a newer CLI. The private copy is how the extension guarantees itself a compatible CLI. It is not how the user's shell gets one: a newer CLI on PATH is used as is while it satisfies the C-34 range, and is never replaced."
type: business
enforcement: error

Expand Down Expand Up @@ -793,7 +793,7 @@ spec:
priority: medium

- id: AC-50
description: "With specter.version unset (the default empty string), downloadBinary resolves the target version by reading ctx.extension.packageJSON.version — a v0.10.0 VSIX fetches specter_0.10.0_<os>_<arch>.<ext>, not whatever /releases/latest returns. With specter.version='latest', downloadBinary queries /releases/latest (previous default behavior, now explicit opt-in). With specter.version pinned to a semver like '0.9.2', downloadBinary uses that string verbatim. package.json declares the config default as the empty string, not 'latest'."
description: "With specter.version unset (the default empty string), the private copy's version is package.json's `specterCli.default`, so an extension declaring 0.15.1 fetches specter_0.15.1_<os>_<arch>.<ext> whatever its own version, not whatever /releases/latest returns. With specter.version='latest', downloadBinary queries /releases/latest (previous default behavior, now explicit opt-in). With specter.version pinned to a semver like '0.9.2', downloadBinary uses that string verbatim. package.json declares the config default as the empty string, not 'latest'."
references_constraints: ["C-27"]
priority: high

Expand Down Expand Up @@ -1664,6 +1664,21 @@ spec:
references_constraints: ["C-34"]
priority: high

- id: AC-83
description: "The private copy's default version is declared, not inferred from the extension's own version. package.json carries `specterCli.default`, a plain MAJOR.MINOR.PATCH that equals the repository's VERSION file and satisfies `specterCli.range`, so the extension that ships fetches by default exactly the CLI that ships with it, whatever number the extension itself carries. privateVersionFor returns that default when `specter.version` is empty, the string `latest` when the setting is `latest` so the caller resolves it, and the setting's own value when it names a version. An extension whose package.json lacks the field cannot resolve a private copy at all, and says so."
inputs:
package_json: "specterCli.default 0.15.1, specterCli.range >=0.15.0 <0.16.0, version 0.15.2"
settings: "empty; latest; 0.15.0"
expected_output:
default_equals_repository_VERSION: true
default_satisfies_range: true
empty_setting: "0.15.1"
latest_setting: "latest"
pinned_setting: "0.15.0"
missing_field: "an error naming specterCli.default"
references_constraints: ["C-27", "C-34"]
priority: critical

depends_on:
- spec_id: spec-parse
version_range: "^1.1.0"
Expand All @@ -1682,6 +1697,11 @@ spec:
relationship: requires

changelog:
- version: "7.0.0"
date: "2026-09-17"
author: "specter-team"
type: major
description: "C-27 now names the CLI version the private copy fetches by default: package.json's `specterCli.default`, which must equal the repository's VERSION and satisfy `specterCli.range`, both bound by AC-83. The extension's own version was the default, which pairs every extension version to a CLI release of the same number. That pairing blocked the first stable publication of the C-34 fix: 0.15.1 was already on the Marketplace as pre-release, the Marketplace never accepts a number twice, and a 0.15.2 extension would have tried to fetch a CLI 0.15.2 that does not exist. AC-50 reads the declared default. Major: an implementation that reads its own version no longer conforms."
- version: "6.0.0"
date: "2026-09-14"
author: "specter-team"
Expand Down
3 changes: 2 additions & 1 deletion specter/vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"vscode": "^1.85.0"
},
"specterCli": {
"default": "0.15.1",
"range": ">=0.15.0 <0.16.0"
},
"categories": [
Expand Down Expand Up @@ -62,7 +63,7 @@
"type": "string",
"default": "",
"scope": "machine",
"description": "Version of the extension's own CLI copy under ~/.specter/cli. Empty (default) matches the extension version. Set 'latest' to always track the newest GitHub release, or pin a specific version (e.g. '0.9.2'). A CLI on PATH inside the supported range is used regardless of this setting. Machine-scoped: ignored by workspace and folder settings."
"description": "Version of the extension's own CLI copy under ~/.specter/cli. Empty (default) uses the CLI version this extension build declares. Set 'latest' to always track the newest GitHub release, or pin a specific version (e.g. '0.9.2'). A CLI on PATH inside the supported range is used regardless of this setting. Machine-scoped: ignored by workspace and folder settings."
},
"specter.showInsightsOnFailure": {
"type": "boolean",
Expand Down
2 changes: 1 addition & 1 deletion specter/vscode-extension/src/__tests__/binary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ describe('[spec-vscode/AC-50] specter.version config default (C-27)', () => {
// Schema drift guard: if a future change reverts to 'latest' as the
// default, version skew between the Marketplace extension and the
// GoReleaser-produced GitHub Release reappears. Keep the default empty
// so downloadBinary reads ctx.extension.packageJSON.version.
// so the private copy uses package.json's specterCli.default.
});
});

Expand Down
57 changes: 57 additions & 0 deletions specter/vscode-extension/src/__tests__/cliDefault.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// @spec spec-vscode
//
// C-27 as of 7.0.0: the private CLI copy fetches the version package.json
// declares under specterCli.default, not the extension's own version. The
// new function is reached through require() so the red is a runtime
// failure rather than a build failure under strict ts-jest.

import * as fs from 'fs';
import * as path from 'path';

// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any
const mod: any = require('../binaryDiscovery');

const PKG = { version: '0.15.2', specterCli: { default: '0.15.1', range: '>=0.15.0 <0.16.0' } };

// @ac AC-83
describe('[spec-vscode/AC-83] privateVersionFor: the declared default, not the extension version', () => {
it('exports privateVersionFor', () => {
expect(typeof mod.privateVersionFor).toBe('function');
});

it('an empty setting yields specterCli.default, even when the extension version differs', () => {
expect(mod.privateVersionFor('', PKG)).toBe('0.15.1');
});

it('the latest setting is passed through for the caller to resolve', () => {
expect(mod.privateVersionFor('latest', PKG)).toBe('latest');
});

it('a pinned setting is used as is', () => {
expect(mod.privateVersionFor('0.15.0', PKG)).toBe('0.15.0');
});

it('a build without the field cannot resolve a private copy and names the field', () => {
expect(() => mod.privateVersionFor('', { version: '0.15.2', specterCli: { range: '>=0.15.0 <0.16.0' } })).toThrow(/specterCli\.default/);
expect(() => mod.privateVersionFor('', { version: '0.15.2' })).toThrow(/specterCli\.default/);
});
});

// @ac AC-83
describe('[spec-vscode/AC-83] package.json declares the CLI the repository ships', () => {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8'));
const repoVersion = fs.readFileSync(path.join(__dirname, '..', '..', '..', 'VERSION'), 'utf8').trim();

it('specterCli.default is a plain MAJOR.MINOR.PATCH', () => {
expect(pkg.specterCli).toBeDefined();
expect(pkg.specterCli.default).toMatch(/^\d+\.\d+\.\d+$/);
});

it('specterCli.default equals the repository VERSION file', () => {
expect(pkg.specterCli.default).toBe(repoVersion);
});

it('specterCli.default satisfies specterCli.range', () => {
expect(mod.satisfiesRange(pkg.specterCli.default, pkg.specterCli.range)).toBe(true);
});
});
19 changes: 19 additions & 0 deletions specter/vscode-extension/src/binaryDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,3 +492,22 @@ export function terminalInvocation(binaryPath: string | null, args: string, plat
// PowerShell needs the call operator to run a quoted path.
return `& '${bin.replace(/'/g, "''")}' ${args}`;
}

/**
* C-27: the version the private copy should be. package.json declares it
* under specterCli.default, which a test binds to the repository's VERSION
* file, so the extension fetches by default exactly the CLI that shipped
* with it whatever number the extension itself carries. The setting
* overrides it: `latest` is returned as is for the caller to resolve over
* the network, and any other non-empty value is used verbatim. A build
* whose package.json lacks the field is broken and says so.
*/
export function privateVersionFor(setting: string, pkg: { specterCli?: { default?: string } }): string {
const declared = pkg.specterCli?.default;
if (!declared) {
throw new Error('this extension build declares no CLI version to fetch (package.json specterCli.default). Reinstall the extension.');
}
if (setting === 'latest') return 'latest';
if (setting) return setting;
return declared;
}
23 changes: 14 additions & 9 deletions specter/vscode-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
planRedownload,
privateCliDir,
installUserCopy,
privateVersionFor,
BinaryPlan,
shellInstallDecision,
terminalInvocation,
Expand Down Expand Up @@ -381,16 +382,20 @@ async function resolveBinary(ctx: vscode.ExtensionContext): Promise<string | nul
*/
async function resolvePrivateVersion(ctx: vscode.ExtensionContext): Promise<string | null> {
const versionSetting = vscode.workspace.getConfiguration('specter').get<string>('version', '');
if (versionSetting === 'latest') {
try {
return await resolveLatestVersion();
} catch (e) {
vscode.window.showErrorMessage(`Specter: could not resolve the latest release: ${e}`, { modal: true });
return null;
}
let wanted: string;
try {
wanted = privateVersionFor(versionSetting, ctx.extension.packageJSON);
} catch (e) {
vscode.window.showErrorMessage(`Specter: ${e instanceof Error ? e.message : String(e)}`, { modal: true });
return null;
}
if (wanted !== 'latest') return wanted;
try {
return await resolveLatestVersion();
} catch (e) {
vscode.window.showErrorMessage(`Specter: could not resolve the latest release: ${e}`, { modal: true });
return null;
}
if (versionSetting) return versionSetting;
return ctx.extension.packageJSON.version as string;
}

async function downloadBinary(ctx: vscode.ExtensionContext, target: { version: string; target: string }): Promise<string | null> {
Expand Down
Loading