mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-26 20:42:56 +08:00
Resolve compatible stable extension releases across browser and CLI
This commit is contained in:
+3
-1
@@ -16,9 +16,11 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
- Add project-scoped framework and semantic extensions, managed package installation, worker loading, and graph refresh on install/update/enable/disable/remove. Failed activation preserves the previous project configuration and graph.
|
||||
- Add an external Drupal extension for declared routes/forms, services, hooks, plugins and event subscribers. Literal route paths can be explore endpoints; extension edges retain their labels and registration sites. Comments and quoted examples do not count as PHP invocations.
|
||||
- Add a local marketplace preview with signed publisher submissions, immutable releases, publisher ownership checks and a companion connection for project selection. Mobile detail pages retain version, compatibility and source information. Hosting and compatible-version selection remain follow-up work; this is not a release announcement.
|
||||
- Add a local marketplace preview with signed publisher submissions, immutable releases, publisher ownership checks and a companion connection for project selection. Mobile detail pages retain version, compatibility and source information. Hosted deployment remains follow-up work; this is not a release announcement.
|
||||
- Extension authors can generate a starter with `codegraph extensions create`, test real graph contributions and removal with `codegraph extensions test`, and use the public SDK types and author guide without private core imports.
|
||||
|
||||
- Marketplace and headless registry installs now select the highest stable semantic version compatible with the connected engine and extension API. Automatic updates refuse downgrades; explicit pins (including prereleases) never substitute another version. Downloaded identity, compatibility and integrity are rechecked locally.
|
||||
|
||||
### Highlights
|
||||
|
||||
- **`codegraph ui` — your graph in a browser.** A local, read-only viewer for the project you already indexed: your code with its callers and callees in the margin, a map of the whole repository, and a strip that shows how one symbol reaches another.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import * as http from 'node:http';
|
||||
import { resolveRelease } from '../src/plugins/releases';
|
||||
import { ExtensionManager } from '../src/plugins/manager';
|
||||
import { sha256 } from '../src/plugins/package';
|
||||
import { clearProjectConfigCache } from '../src/project-config';
|
||||
import { version as engineVersion } from '../package.json';
|
||||
|
||||
const release = (version: string, extra: Record<string, unknown> = {}) => ({ id: 'example', version,
|
||||
engines: '>=1.6.0 <2', apiVersion: 1, capabilities: ['frameworks'], integrity: 'a'.repeat(64), ...extra });
|
||||
const choose = (rows: unknown, options = {}) => resolveRelease(rows, 'example', { engineVersion, ...options });
|
||||
|
||||
describe('compatible registry selection', () => {
|
||||
it('chooses highest compatible stable semver regardless of array/publication order', () => {
|
||||
const rows = [release('1.9.0'), release('4.0.0', { engines: '>=9' }), release('1.10.0'), release('1.2.0'), release('10.0.0-beta.1')];
|
||||
expect(choose(rows).version).toBe('1.10.0');
|
||||
expect(choose(rows.reverse()).version).toBe('1.10.0');
|
||||
expect(choose([release('2.0.0', { engines: '>=2' }), release('1.0.0')], { engineVersion: '2.1.0' }).version).toBe('2.0.0');
|
||||
});
|
||||
it('skips invalid metadata and unsupported APIs instead of adopting them', () => {
|
||||
expect(choose([null, release('bad'), release('9.0.0', { apiVersion: 2 }), release('8.0.0', { engines: 'nonsense' }),
|
||||
release('7.0.0', { integrity: 'bad' }), release('6.0.0', { id: 'other' }), release('5.0.0', { capabilities: ['languages'] }),
|
||||
release('4.0.0', { apiVersion: undefined }), release('1.0.0')]).version).toBe('1.0.0');
|
||||
expect(() => choose({})).toThrow('invalid release catalog');
|
||||
expect(() => choose([release('1.0.0'), release('1.0.0')])).toThrow('duplicate immutable');
|
||||
});
|
||||
it('requires exact opt-in to prereleases and never substitutes an incompatible or absent pin', () => {
|
||||
const rows = [release('2.0.0-beta.1'), release('1.0.0'), release('3.0.0', { engines: '>=9' })];
|
||||
expect(choose(rows, { version: '2.0.0-beta.1' }).version).toBe('2.0.0-beta.1');
|
||||
expect(() => choose(rows, { version: '3.0.0' })).toThrow('requires CodeGraph >=9');
|
||||
expect(() => choose(rows, { version: '1.5.0' })).toThrow('Release not found');
|
||||
expect(() => choose(rows, { version: '^1' })).toThrow('exact semantic version');
|
||||
expect(() => choose([release('2.0.0-beta.1')])).toThrow('prerelease requires an exact');
|
||||
});
|
||||
it('honors disjoint engine ranges and prerelease engine semantics', () => {
|
||||
expect(choose([release('1.0.0', { engines: '^1.6.0 || ^3.0.0' })]).version).toBe('1.0.0');
|
||||
expect(() => choose([release('1.0.0', { engines: '^1.6.0' })], { engineVersion: '1.7.0-beta.1' })).toThrow('No compatible');
|
||||
expect(choose([release('1.0.0', { engines: '>=1.7.0-beta.1 <2' })], { engineVersion: '1.7.0-beta.1' }).version).toBe('1.0.0');
|
||||
});
|
||||
it('refuses automatic downgrades but permits explicit requested versions', () => {
|
||||
expect(() => choose([release('1.0.0')], { currentVersion: '2.0.0' })).toThrow('would downgrade');
|
||||
expect(choose([release('1.0.0')], { version: '1.0.0', currentVersion: '2.0.0' }).version).toBe('1.0.0');
|
||||
expect(choose([release('2.0.0')], { currentVersion: '2.0.0' }).version).toBe('2.0.0');
|
||||
expect(() => choose([], { currentVersion: '2.0.0' })).toThrow('installed 2.0.0 is unchanged');
|
||||
});
|
||||
});
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { clearProjectConfigCache(); roots.splice(0).forEach(r => fs.rmSync(r, { recursive: true, force: true })); });
|
||||
function artifact(extra: Record<string, unknown> = {}, version = '1.0.0') {
|
||||
return Buffer.from(JSON.stringify({ format: 'codegraph-extension-1', package: { name: 'example', version, main: 'index.cjs',
|
||||
codegraph: { id: 'example', apiVersion: 1, capabilities: ['frameworks'], ...extra } }, files: { 'index.cjs': 'module.exports=()=>({frameworks:[]});' } }));
|
||||
}
|
||||
|
||||
describe('trusted registry installer', () => {
|
||||
it('revalidates identity, version, API, engine and integrity without altering existing config/graph', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-release-')); roots.push(root);
|
||||
const config = JSON.stringify({ plugins: [{ name: 'managed:example', version: '0.9.0', enabled: true }] });
|
||||
fs.writeFileSync(path.join(root, 'codegraph.json'), config);
|
||||
fs.mkdirSync(path.join(root, '.codegraph')); fs.writeFileSync(path.join(root, '.codegraph/codegraph.db'), 'unchanged graph sentinel');
|
||||
let bytes = artifact(), rows: unknown = [];
|
||||
const server = http.createServer((req, res) => { res.end(req.url?.startsWith('/api/extensions/') ? JSON.stringify(rows) : bytes); });
|
||||
await new Promise<void>(r => server.listen(0, '127.0.0.1', r));
|
||||
const registry = `http://127.0.0.1:${(server.address() as { port: number }).port}`, manager = new ExtensionManager(root);
|
||||
try {
|
||||
for (const [payload, error] of [
|
||||
[artifact({ id: 'other' }), 'identity/version'], [artifact({}, '2.0.0'), 'identity/version'],
|
||||
[artifact({ apiVersion: 2 }), 'unsupported API'], [artifact({ engines: '>=9' }), 'requires CodeGraph'],
|
||||
] as const) {
|
||||
bytes = payload; rows = [release('1.0.0', { integrity: sha256(bytes) })];
|
||||
await expect(manager.installFromRegistry({ registry, id: 'example' })).rejects.toThrow(error);
|
||||
}
|
||||
bytes = artifact(); rows = [release('1.0.0')];
|
||||
await expect(manager.installFromRegistry({ registry, id: 'example' })).rejects.toThrow('integrity');
|
||||
rows = [release('1.0.0', { engines: '>=9' })];
|
||||
await expect(manager.installFromRegistry({ registry, id: 'example' })).rejects.toThrow('No compatible');
|
||||
rows = [release('1.0.0', { integrity: sha256(bytes) })];
|
||||
await expect(manager.installFromRegistry({ registry, id: 'example', selected: { version: '0.9.0', integrity: sha256(bytes) } })).rejects.toThrow('changed since selection');
|
||||
await expect(manager.installFromRegistry({ registry, id: 'missing', update: true })).rejects.toThrow('not installed');
|
||||
expect(fs.readFileSync(path.join(root, 'codegraph.json'), 'utf8')).toBe(config);
|
||||
expect(fs.readFileSync(path.join(root, '.codegraph/codegraph.db'), 'utf8')).toBe('unchanged graph sentinel');
|
||||
expect(fs.existsSync(path.join(root, '.codegraph/plugins/packages'))).toBe(false);
|
||||
} finally { await new Promise<void>(r => server.close(() => r())); }
|
||||
});
|
||||
it('checks the actual destination again under the install lock', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-release-race-')); roots.push(root);
|
||||
const config = JSON.stringify({ plugins: [{ name: 'managed:example', version: '3.0.0' }] });
|
||||
fs.writeFileSync(path.join(root, 'codegraph.json'), config);
|
||||
await expect(new ExtensionManager(root).install({ bytes: artifact(), automatic: true })).rejects.toThrow('would downgrade');
|
||||
expect(fs.readFileSync(path.join(root, 'codegraph.json'), 'utf8')).toBe(config);
|
||||
});
|
||||
});
|
||||
@@ -187,3 +187,47 @@ Do not deploy or publish to npm as part of this author exercise. Other preview
|
||||
gaps remain: compatible-version fallback, crash/stale-lock recovery and broader
|
||||
Drupal accuracy/performance review. The prior Drupal sample measured about
|
||||
22–30% rebuild overhead versus the built-in resolver; it is not a general SLA.
|
||||
|
||||
|
||||
## Select compatible registry releases
|
||||
|
||||
The connected local engine selects the highest stable semantic version matching
|
||||
its own CodeGraph version and extension API, independent of publication time.
|
||||
The marketplace shows that selected version and destination before enabling
|
||||
Install. Switching destinations refreshes the selection. An incompatible latest
|
||||
release does not prevent installation of an older compatible release. A missing,
|
||||
invalid or unsupported API marker is not assumed compatible.
|
||||
|
||||
The same selection is available without a browser:
|
||||
|
||||
```sh
|
||||
codegraph extensions install python-events --registry http://127.0.0.1:PORT --path /absolute/path/python-app
|
||||
codegraph extensions update python-events --registry http://127.0.0.1:PORT --path /absolute/path/python-app
|
||||
codegraph extensions install python-events --registry http://127.0.0.1:PORT --version 0.1.0 --path /absolute/path/python-app
|
||||
```
|
||||
|
||||
Use the actual registry origin in place of the example URL. Remote registries
|
||||
must use HTTPS; loopback HTTP is supported for development. `--registry` makes
|
||||
the positional argument an extension ID. Without it, the argument remains an
|
||||
exact package file or URL; `--version` is rejected for those artifact inputs.
|
||||
|
||||
Automatic selection never adopts prereleases or downgrades an installed
|
||||
version. Opt into a preview using an exact `--version 0.2.0-beta.1` pin. Exact
|
||||
pins may intentionally select an older version, but must still match the
|
||||
running engine and supported API; they never fall back. Semver engine ranges
|
||||
use standard prerelease exclusion rules, so an engine preview must be explicitly
|
||||
allowed by the package range. Build metadata does not change semver precedence;
|
||||
equal-precedence versions have a deterministic lexical tie break.
|
||||
|
||||
No compatible release leaves the current configuration and graph unchanged and
|
||||
reports the rejected ranges/APIs. Catalogs are selection hints: the local
|
||||
installer rechecks the downloaded package ID, version, engine/API compatibility
|
||||
and SHA-256 digest. A changed browser selection must be refreshed before
|
||||
installing. Failed activation still rolls back the configuration and graph.
|
||||
|
||||
The companion's authenticated `resolve` command accepts `project`, `id`, and an
|
||||
optional exact `version`. Registry `install`/`update` commands use the same fields
|
||||
and may bind the displayed selection via `selected: { version, integrity }`.
|
||||
They resolve only against the connected marketplace. Existing exact `url` plus
|
||||
`integrity` commands keep their artifact semantics. All commands retain the
|
||||
origin, window, local-host and bearer checks.
|
||||
|
||||
+42
-13
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
const app = document.querySelector('#app');
|
||||
const dialog = document.querySelector('#connect-dialog');
|
||||
const state = { catalog: [], tab: 'all', search: '', connection: null, snapshot: null, project: '0', popup: null, pending: new Map(), notice: '' };
|
||||
const state = { catalog: [], tab: 'all', search: '', connection: null, snapshot: null, project: '0', popup: null, pending: new Map(), selections: {}, selectionEpoch: 0, notice: '' };
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const icon = '<svg viewBox="0 0 40 48" aria-hidden="true"><path fill="currentColor" d="M21 2C18 12 5 19 5 30a15 15 0 0 0 30 0C35 19 24 13 21 2Z"/><path d="M11 31c4 4 14 4 18-1" stroke="#2367d8" stroke-width="3" fill="none"/></svg>';
|
||||
function readConnection(value) {
|
||||
@@ -29,13 +29,38 @@ async function api(path, options) {
|
||||
}
|
||||
function project() { return state.snapshot?.projects.find(p => p.id === state.project); }
|
||||
function installed(id) { return project()?.extensions.find(e => e.name === `managed:${id}`); }
|
||||
function selected(extension) { return state.selections[extension.id]?.release || extension; }
|
||||
function compatibility(extension) {
|
||||
if (!state.snapshot) return 'Connect a project to select a compatible stable release.';
|
||||
const choice = state.selections[extension.id];
|
||||
if (!choice) return 'Checking compatibility for this destination…';
|
||||
if (choice.error) return choice.error;
|
||||
return `Selected ${choice.release.version} · compatible with CodeGraph ${state.snapshot.version}, API ${state.snapshot.apiVersion} · ${project()?.name}`;
|
||||
}
|
||||
async function refreshSelections() {
|
||||
const epoch = ++state.selectionEpoch;
|
||||
state.selections = {};
|
||||
if (!state.snapshot || state.snapshot.busy) return;
|
||||
const destination = state.project;
|
||||
if (location.pathname !== '/publish') render();
|
||||
await Promise.all(state.catalog.map(async extension => {
|
||||
let choice;
|
||||
try { choice = { release: await rpc('command', { action: 'resolve', id: extension.id, project: destination }) }; }
|
||||
catch (error) { choice = { error: error.message }; }
|
||||
if (epoch !== state.selectionEpoch || destination !== state.project) return;
|
||||
state.selections[extension.id] = choice;
|
||||
}));
|
||||
if (epoch === state.selectionEpoch && location.pathname !== '/publish') render();
|
||||
}
|
||||
function button(extension, detail = false) {
|
||||
extension = selected(extension);
|
||||
const current = installed(extension.id);
|
||||
if (state.snapshot?.busy) return '<button class="primary" disabled>Updating graph…</button>';
|
||||
const ready = !state.snapshot || !!state.selections[extension.id]?.release;
|
||||
if (current) {
|
||||
return `<div class="manage-actions">${current.version !== extension.version ? `<button class="primary" data-action="update" data-id="${escape(extension.id)}">Update to ${escape(extension.version)}</button>` : ''}<button data-action="${current.enabled === false ? 'enable' : 'disable'}" data-id="${escape(extension.id)}">${current.enabled === false ? 'Enable' : 'Disable'}</button>${detail ? `<button data-action="remove" data-id="${escape(extension.id)}">Remove</button>` : ''}</div>`;
|
||||
return `<div class="manage-actions">${ready && current.version !== extension.version ? `<button class="primary" data-action="update" data-id="${escape(extension.id)}">Update to ${escape(extension.version)}</button>` : ''}<button data-action="${current.enabled === false ? 'enable' : 'disable'}" data-id="${escape(extension.id)}">${current.enabled === false ? 'Enable' : 'Disable'}</button>${detail ? `<button data-action="remove" data-id="${escape(extension.id)}">Remove</button>` : ''}</div>`;
|
||||
}
|
||||
return `<button class="primary" data-action="install" data-id="${escape(extension.id)}">Install extension <span>↓</span></button>`;
|
||||
return `<button class="primary" data-action="install" data-id="${escape(extension.id)}" ${ready ? '' : 'disabled'}>Install extension <span>↓</span></button>`;
|
||||
}
|
||||
function connectionPanel() {
|
||||
const p = project();
|
||||
@@ -46,8 +71,9 @@ function progress() {
|
||||
return `<div class="progress ${state.snapshot.error ? 'failed' : ''}" role="status">${escape(state.snapshot.error || state.snapshot.progress.message)}</div>`;
|
||||
}
|
||||
function card(extension) {
|
||||
extension = selected(extension);
|
||||
const current = installed(extension.id);
|
||||
return `<article class="extension-card"><div class="card-top"><div class="extension-icon">${extension.id === 'drupal' ? icon : escape(extension.name.slice(0, 1))}</div><div><h2><a data-link href="/extensions/${escape(extension.id)}">${escape(extension.name)}</a></h2><div class="byline">${escape(extension.publisher)} ${extension.official ? '<span class="official">✓ OFFICIAL</span>' : '<span>Community</span>'}</div></div><span class="version">v${escape(extension.version)}</span></div><p class="card-copy">${escape(extension.description)}</p><div class="tags">${extension.id === 'drupal' ? '<span class="tag">PHP</span><span class="tag">Drupal 8–11</span>' : ''}${extension.capabilities.map(c => `<span class="tag">${c === 'frameworks' ? 'Framework' : 'Semantic analysis'}</span>`).join('')}</div>${extension.id === 'drupal' ? '<div class="graph" aria-label="Example framework flow: route to controller to service"><span class="graph-node">/your-route</span><span class="graph-line"></span><span class="graph-node blue">Controller</span><span class="graph-line"></span><span class="graph-node">Service</span></div>' : ''}<div class="card-bottom"><a data-link class="text-link" href="/extensions/${escape(extension.id)}">${current ? (current.enabled === false ? 'Disabled · Manage →' : 'Installed · Manage →') : 'Explore extension ↗'}</a>${button(extension)}</div></article>`;
|
||||
return `<article class="extension-card"><div class="card-top"><div class="extension-icon">${extension.id === 'drupal' ? icon : escape(extension.name.slice(0, 1))}</div><div><h2><a data-link href="/extensions/${escape(extension.id)}">${escape(extension.name)}</a></h2><div class="byline">${escape(extension.publisher)} ${extension.official ? '<span class="official">✓ OFFICIAL</span>' : '<span>Community</span>'}</div></div><span class="version">v${escape(extension.version)}</span></div><p class="card-copy">${escape(extension.description)}</p><div class="tags">${extension.id === 'drupal' ? '<span class="tag">PHP</span><span class="tag">Drupal 8–11</span>' : ''}${extension.capabilities.map(c => `<span class="tag">${c === 'frameworks' ? 'Framework' : 'Semantic analysis'}</span>`).join('')}</div>${extension.id === 'drupal' ? '<div class="graph" aria-label="Example framework flow: route to controller to service"><span class="graph-node">/your-route</span><span class="graph-line"></span><span class="graph-node blue">Controller</span><span class="graph-line"></span><span class="graph-node">Service</span></div>' : ''}<p class="small muted compatibility">${escape(compatibility(extension))}</p><div class="card-bottom"><a data-link class="text-link" href="/extensions/${escape(extension.id)}">${current ? (current.enabled === false ? 'Disabled · Manage →' : 'Installed · Manage →') : 'Explore extension ↗'}</a>${button(extension)}</div></article>`;
|
||||
}
|
||||
function renderCatalog() {
|
||||
app.innerHTML = `<section class="hero"><div><span class="eyebrow">THE CODEGRAPH MARKETPLACE</span><h1>Make your graph<br>speak your framework.</h1><p>Add the connections that matter to your codebase.<br>Discover extensions, install in a click, and keep<br>your intelligence local.</p></div>${connectionPanel()}</section><section class="section"><div class="toolbar"><div class="tabs"><button class="tab ${state.tab === 'all' ? 'active' : ''}" data-tab="all">All extensions<span class="count">${state.catalog.length}</span></button><button class="tab ${state.tab === 'official' ? 'active' : ''}" data-tab="official">Official</button><button class="tab ${state.tab === 'installed' ? 'active' : ''}" data-tab="installed">Installed<span class="count">${project()?.extensions.length || 0}</span></button></div><label class="search">⌕<input id="search" aria-label="Search extensions" placeholder="Search frameworks, capabilities…" value="${escape(state.search)}"></label></div>${progress()}<div class="catalog-label"><span>${state.tab === 'installed' ? 'YOUR EXTENSIONS' : 'EXTEND WHAT YOUR GRAPH UNDERSTANDS'}</span><span>FRAMEWORK + SEMANTIC EXTENSIONS</span></div><div id="catalog-items"></div><div class="note-row"><span>◉ Source code stays on your machine</span><span>↻ Updates you control</span><span>◇ Open extension API</span></div></section>`;
|
||||
@@ -59,10 +85,11 @@ function renderCards() {
|
||||
document.querySelector('#catalog-items').innerHTML = `<div class="catalog">${filtered.length ? filtered.map(card).join('') : `<div class="empty">${state.tab === 'installed' && !state.snapshot ? 'Connect CodeGraph to see your installed extensions.' : state.search ? 'No extensions match your search.' : 'No extensions installed yet.'}</div>`}${state.tab !== 'installed' ? '<aside class="author-card"><span class="eyebrow">FOR BUILDERS</span><div class="author-art" aria-hidden="true">{↗}</div><h2>Your framework.<br>Your contribution.</h2><p>Turn what you know into an extension everyone can use. Start with the public API and publish your first release.</p><a data-link href="/publish">Build an extension <span>↗</span></a></aside>' : ''}</div>`;
|
||||
}
|
||||
function renderDetail(id) {
|
||||
const e = state.catalog.find(item => item.id === id);
|
||||
const item = state.catalog.find(item => item.id === id);
|
||||
const e = item && selected(item);
|
||||
if (!e) { app.innerHTML = '<section class="section detail"><h1>Extension not found</h1><a data-link href="/">Back to marketplace</a></section>'; return; }
|
||||
const current = installed(id);
|
||||
app.innerHTML = `<section class="section detail"><div class="breadcrumb"><a data-link href="/">Marketplace</a> / ${escape(e.name)}</div><div class="detail-layout"><article><div class="card-top"><div class="extension-icon">${id === 'drupal' ? icon : escape(e.name.slice(0,1))}</div><div><h2>${escape(e.name)}</h2><div class="byline">${escape(e.publisher)} ${e.official ? '<span class="official">✓ OFFICIAL</span>' : 'Community publisher'}</div></div></div><p class="card-copy">${escape(e.description)}</p><div class="tags">${e.capabilities.map(c => `<span class="tag">${c === 'frameworks' ? 'Framework support' : 'Semantic analysis'}</span>`).join('')}</div><div class="readme">${escape(e.readme)}</div></article><aside class="metadata"><span class="eyebrow">${current ? (current.enabled === false ? 'INSTALLED · DISABLED' : 'INSTALLED') : 'READY FOR YOUR PROJECT'}</span>${button(e,true)}${progress()}<p class="small muted">${project() ? `Destination: ${escape(project().name)}` : 'Connect CodeGraph once to install.'}</p><p class="small muted">Extensions run with your local permissions. Review the publisher and source before installing.</p><dl><dt>Version</dt><dd>${escape(e.version)}</dd><dt>CodeGraph compatibility</dt><dd>${escape(e.engines)}</dd><dt>Source</dt><dd><a href="${escape(e.source)}" target="_blank" rel="noopener">View source repository ↗</a></dd><dt>Published</dt><dd>${escape(new Date(e.publishedAt).toLocaleDateString(undefined,{year:'numeric',month:'long',day:'numeric'}))}</dd><dt>Publisher identity</dt><dd class="integrity">${escape(e.publisherId)}</dd><dt>Release fingerprint</dt><dd class="integrity">${escape(e.integrity)}</dd></dl><a class="text-link" href="/api/download/${escape(e.id)}/${escape(e.version)}">Download for CLI install ↓</a></aside></div></section>`;
|
||||
app.innerHTML = `<section class="section detail"><div class="breadcrumb"><a data-link href="/">Marketplace</a> / ${escape(e.name)}</div><div class="detail-layout"><article><div class="card-top"><div class="extension-icon">${id === 'drupal' ? icon : escape(e.name.slice(0,1))}</div><div><h2>${escape(e.name)}</h2><div class="byline">${escape(e.publisher)} ${e.official ? '<span class="official">✓ OFFICIAL</span>' : 'Community publisher'}</div></div></div><p class="card-copy">${escape(e.description)}</p><div class="tags">${e.capabilities.map(c => `<span class="tag">${c === 'frameworks' ? 'Framework support' : 'Semantic analysis'}</span>`).join('')}</div><div class="readme">${escape(e.readme)}</div></article><aside class="metadata"><span class="eyebrow">${current ? (current.enabled === false ? 'INSTALLED · DISABLED' : 'INSTALLED') : 'READY FOR YOUR PROJECT'}</span>${button(e,true)}<p class="small muted compatibility">${escape(compatibility(e))}</p>${progress()}<p class="small muted">${project() ? `Destination: ${escape(project().name)}` : 'Connect CodeGraph once to install.'}</p><p class="small muted">Extensions run with your local permissions. Review the publisher and source before installing.</p><dl><dt>Version</dt><dd>${escape(e.version)}</dd><dt>CodeGraph compatibility</dt><dd>${escape(e.engines)}</dd><dt>Source</dt><dd><a href="${escape(e.source)}" target="_blank" rel="noopener">View source repository ↗</a></dd><dt>Published</dt><dd>${escape(new Date(e.publishedAt).toLocaleDateString(undefined,{year:'numeric',month:'long',day:'numeric'}))}</dd><dt>Publisher identity</dt><dd class="integrity">${escape(e.publisherId)}</dd><dt>Release fingerprint</dt><dd class="integrity">${escape(e.integrity)}</dd></dl><a class="text-link" href="/api/download/${escape(e.id)}/${escape(e.version)}">Download for CLI install ↓</a></aside></div></section>`;
|
||||
}
|
||||
function renderPublish() {
|
||||
app.innerHTML = `<section class="section publish"><a data-link class="breadcrumb" href="/">← Marketplace</a><h1>Share what you know.</h1><p class="publish-intro">Give CodeGraph the framework knowledge your team needs. Upload a versioned extension and publish it to the community.</p><div class="publish-layout"><form id="publish-form"><div class="upload"><label for="artifact">Your extension package</label><p class="small muted">Upload the .cgext file from CodeGraph’s extension pack command.</p><input type="file" id="artifact" accept=".cgext,application/json" required><p id="package-summary" class="small"></p></div><div class="form-row"><div><label for="name">Extension name</label><input id="name" maxlength="80" placeholder="Your framework" required></div><div><label for="publisher">Publisher name</label><input id="publisher" maxlength="80" placeholder="Your name or team" required></div></div><label for="description">Short description</label><input id="description" maxlength="240" placeholder="What does this extension help people understand?" required><label for="source">Source repository</label><input id="source" type="url" placeholder="https://github.com/your-team/your-extension" required><label for="readme">Documentation</label><textarea id="readme" rows="8" maxlength="30000" placeholder="Supported patterns, configuration, examples, and known limitations." required></textarea><div class="form-actions"><button class="primary" type="submit">Publish release <span>↗</span></button><span class="small muted">Published versions are immutable.<br>Community publication does not confer official status.</span></div><p id="publish-result" role="status"></p></form><aside class="aside-notes"><span class="eyebrow">BEFORE YOU PUBLISH</span><h3>Build. Test. Share.</h3><p>Package framework or semantic contributions, and test deterministic indexing before publishing.</p><h3>Your publisher identity</h3><p>Your browser creates a signing key for your releases. Back it up to publish updates from another browser. Your key never leaves this browser during publication.</p><button id="backup-key">Back up publisher key ↓</button><label for="restore-key">Restore an existing publisher key</label><input type="file" id="restore-key" accept=".json"><p class="small">Keep your backup private. Anyone with it can publish your extensions.</p></aside></div></section>`;
|
||||
@@ -72,7 +99,7 @@ function render() {
|
||||
else if (location.pathname.startsWith('/extensions/')) renderDetail(decodeURIComponent(location.pathname.split('/')[2]));
|
||||
else renderCatalog();
|
||||
}
|
||||
async function refresh() { state.catalog = await api('/api/extensions'); render(); }
|
||||
async function refresh() { state.catalog = await api('/api/extensions'); render(); await refreshSelections(); }
|
||||
function connect() {
|
||||
if (state.connection) { openCompanion(); return; }
|
||||
document.querySelector('#connect-command').textContent = `codegraph extensions connect --marketplace ${location.origin}`;
|
||||
@@ -109,13 +136,14 @@ window.addEventListener('message', event => {
|
||||
if (wasBusy && !state.snapshot.busy && state.notice) toast(state.snapshot.progress.message);
|
||||
document.querySelector('#connect').classList.add('connected');
|
||||
document.querySelector('#connection-label').textContent = 'CodeGraph connected';
|
||||
if (message.type === 'connected') { toast('Connected. Select your project and install an extension.'); render(); }
|
||||
if (message.type === 'connected') { toast('Connected. Select your project and install an extension.'); render(); void refreshSelections(); }
|
||||
else if (JSON.stringify(state.snapshot) !== before && location.pathname !== '/publish') render();
|
||||
if (wasBusy && !state.snapshot.busy) void refreshSelections();
|
||||
}
|
||||
if (message.type === 'disconnected') disconnect();
|
||||
});
|
||||
function disconnect() {
|
||||
state.snapshot = null; document.querySelector('#connect').classList.remove('connected');
|
||||
state.snapshot = null; state.selections = {}; ++state.selectionEpoch; document.querySelector('#connect').classList.remove('connected');
|
||||
document.querySelector('#connection-label').textContent = 'Reconnect CodeGraph';
|
||||
if (location.pathname !== '/publish') render();
|
||||
}
|
||||
@@ -125,8 +153,9 @@ async function act(action, id) {
|
||||
const extension = state.catalog.find(e => e.id === id);
|
||||
const command = {action,id,project:state.project};
|
||||
if (action === 'install' || action === 'update') {
|
||||
command.url = `${location.origin}/api/download/${extension.id}/${extension.version}`;
|
||||
command.integrity = extension.integrity;
|
||||
const release = state.selections[id]?.release;
|
||||
if (!release) { toast('Wait for compatibility selection or reconnect.'); return; }
|
||||
command.selected = { version: release.version, integrity: release.integrity };
|
||||
if (extension.official && id === 'drupal') command.replaces = ['drupal'];
|
||||
}
|
||||
try { await rpc('command',command); state.notice='operation'; toast('CodeGraph is updating your project.'); state.snapshot=await rpc('status'); render(); }
|
||||
@@ -142,7 +171,7 @@ document.addEventListener('click',event=>{
|
||||
});
|
||||
document.addEventListener('input',event=>{if(event.target.id==='search'){state.search=event.target.value;renderCards();}});
|
||||
document.addEventListener('change',async event=>{
|
||||
if(event.target.id==='project'){state.project=event.target.value;render();}
|
||||
if(event.target.id==='project'){state.project=event.target.value;void refreshSelections();}
|
||||
if(event.target.id==='artifact')try{
|
||||
const file=event.target.files[0];if(file.size>8*1024*1024)throw new Error('Package exceeds 8 MiB');
|
||||
const pkg=JSON.parse(await file.text()).package;
|
||||
@@ -179,7 +208,7 @@ document.addEventListener('submit',async event=>{
|
||||
const signature=base64(new Uint8Array(await crypto.subtle.sign({name:'ECDSA',hash:'SHA-256'},key,new TextEncoder().encode(payload))));
|
||||
status.textContent='Validating and publishing your release…';
|
||||
const result=await api('/api/publish',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({payload,publicKey,signature})});
|
||||
state.catalog=await api('/api/extensions');toast('Release published. Back up your publisher key to keep control of updates.');navigate('/extensions/'+result.id);
|
||||
state.catalog=await api('/api/extensions');toast('Release published. Back up your publisher key to keep control of updates.');navigate('/extensions/'+result.id);void refreshSelections();
|
||||
}catch(e){status.textContent=e.message;}finally{submit.disabled=false;}
|
||||
});
|
||||
app.innerHTML='<div class="loading">Loading extensions…</div>';
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Build first; PLAYWRIGHT_MODULE=/absolute/path/to/playwright node scripts/validation/extensions-compatibility.cjs
|
||||
const fs = require('node:fs'), os = require('node:os'), path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { generateKeyPairSync, sign, randomUUID, createHash } = require('node:crypto');
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const { createExtensionProject, CodeGraph } = require('../../dist');
|
||||
const { packExtension } = require('../../dist/plugins/package');
|
||||
const { startMarketplaceServer } = require('../../dist/plugins/marketplace');
|
||||
const { startExtensionBridge } = require('../../dist/plugins/bridge');
|
||||
const repo = path.resolve(__dirname, '../..'), out = path.join(repo, '.qa/recovery/compatibility');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const lab = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-compatible-'));
|
||||
const author = path.join(lab, 'author'), checks = [], commands = [], releases = [];
|
||||
const env = { ...process.env, CODEGRAPH_TELEMETRY: '0', CODEGRAPH_PARSE_WORKERS: '2', CODEGRAPH_RESOLVE_WORKERS: '2', CODEGRAPH_PARALLEL_RESOLVE_MIN: '0' };
|
||||
Object.assign(process.env, env);
|
||||
const roots = ['first-python-project', 'selected-python-project', 'headless-python-project'].map(n => path.join(lab, n));
|
||||
for (const root of roots) {
|
||||
fs.mkdirSync(root); fs.writeFileSync(path.join(root, 'checkout.events.yaml'), 'order.created: send_receipt\n');
|
||||
fs.writeFileSync(path.join(root, 'handlers.py'), 'def send_receipt():\n return "sent"\n');
|
||||
}
|
||||
createExtensionProject(author, 'compatible-demo');
|
||||
const original = fs.readFileSync(path.join(author, 'index.cjs'), 'utf8');
|
||||
const keys = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
let server, bridge, browser, page, registry;
|
||||
async function publish(version, engines = '>=1.6.0 <2', id = 'compatible-demo', broken = false) {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(author, 'package.json'), 'utf8'));
|
||||
pkg.version = version; pkg.codegraph.id = id; pkg.codegraph.engines = engines;
|
||||
fs.writeFileSync(path.join(author, 'package.json'), JSON.stringify(pkg));
|
||||
fs.writeFileSync(path.join(author, 'index.cjs'), original.replace('Python event dispatch', 'Python event dispatch ' + version).replace('extract(file, source) {', broken ? "extract(file, source) { throw Error('Rejected compatible update');" : 'extract(file, source) {'));
|
||||
const bytes = packExtension(author);
|
||||
const payload = JSON.stringify({ name: id, description: 'Compatible generated Python event extension', publisher: 'Compatibility validation',
|
||||
source: 'https://example.com/extension-source', readme: 'Generated author template; local release selection acceptance.', artifact: bytes.toString('base64'), timestamp: Date.now(), nonce: randomUUID() });
|
||||
const body = { payload, publicKey: keys.publicKey.export({ format: 'jwk' }), signature: sign('sha256', Buffer.from(payload), { key: keys.privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64') };
|
||||
const res = await fetch(registry + '/api/publish', { method: 'POST', body: JSON.stringify(body) });
|
||||
assert.equal(res.status, 201, await res.clone().text());
|
||||
releases.push({ id, version, engines, integrity: createHash('sha256').update(bytes).digest('hex') });
|
||||
return { bytes, body };
|
||||
}
|
||||
async function graph(root) {
|
||||
if (!CodeGraph.isInitialized(root)) return [];
|
||||
const g = await CodeGraph.open(root);
|
||||
try { return g.getNodesByKind('route').flatMap(n => g.getOutgoingEdges(n.id).filter(e => e.metadata?.synthesizedBy === 'compatible-demo').map(e => ({ source: n.name, target: g.getNode(e.target)?.name, label: e.metadata.label }))); }
|
||||
finally { g.close(); }
|
||||
}
|
||||
async function expectGraph(root, version) {
|
||||
assert.deepEqual(await graph(root), [{ source: 'event:order.created', target: 'send_receipt', label: 'Python event dispatch ' + version }]);
|
||||
}
|
||||
async function cli(args, expected = 0) {
|
||||
const command = [path.join(repo, 'dist/bin/codegraph.js'), 'extensions', ...args];
|
||||
const start = Date.now();
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, command, { env, cwd: lab, timeout: 120000 });
|
||||
let stdout = '', stderr = ''; child.stdout.on('data', b => stdout += b); child.stderr.on('data', b => stderr += b);
|
||||
child.on('error', reject); child.on('close', exit => resolve({ exit, stdout, stderr }));
|
||||
});
|
||||
commands.push({ command: [process.execPath, ...command], exit: result.exit, seconds: (Date.now() - start) / 1000 });
|
||||
fs.writeFileSync(path.join(out, 'commands.json'), JSON.stringify(commands, null, 2));
|
||||
fs.writeFileSync(path.join(out, `command-${commands.length}.log`), result.stdout + result.stderr);
|
||||
assert.equal(result.exit, expected, result.stderr); return result;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
server = await startMarketplaceServer({ database: path.join(lab, 'registry.db'), publicDirectory: path.join(repo, 'marketplace/public') });
|
||||
registry = `http://127.0.0.1:${server.port}`;
|
||||
// Publication order intentionally differs from semantic order; previews and
|
||||
// a newer incompatible release must never displace the compatible stable one.
|
||||
const pinned = await publish('1.10.0'); await publish('1.9.0'); await publish('0.5.0');
|
||||
await publish('20.0.0-beta.1'); await publish('9.0.0', '>=9'); await publish('1.0.0', '>=9', 'unavailable-demo');
|
||||
const duplicate = await fetch(registry + '/api/publish', { method: 'POST', body: JSON.stringify(pinned.body) });
|
||||
assert.equal(duplicate.status, 400);
|
||||
const all = await (await fetch(registry + '/api/extensions/compatible-demo')).json();
|
||||
assert.deepEqual(all.map(r => r.version), ['9.0.0', '1.10.0', '1.9.0', '0.5.0', '20.0.0-beta.1']);
|
||||
assert.equal(all[0].apiVersion, 1); checks.push('signed immutable versions; semantic catalog order independent of publication order');
|
||||
bridge = await startExtensionBridge(roots.slice(0, 2), registry);
|
||||
browser = await chromium.launch({ executablePath: '/opt/google/chrome/chrome', headless: true, args: ['--no-sandbox'] });
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
page.setDefaultTimeout(60000);
|
||||
const errors = []; page.on('pageerror', e => errors.push(e.message));
|
||||
await page.goto(bridge.connectionUrl);
|
||||
const popupWait = page.waitForEvent('popup'); await page.locator('#connect').click(); const popup = await popupWait;
|
||||
await popup.getByRole('button', { name: 'Allow connection' }).click();
|
||||
await page.getByText('CodeGraph connected', { exact: true }).waitFor();
|
||||
await page.locator('#project').selectOption('1');
|
||||
await page.locator('.compatibility').filter({ hasText: 'Selected 1.10.0' }).waitFor();
|
||||
await page.getByRole('link', { name: 'compatible-demo', exact: true }).click();
|
||||
await page.locator('.compatibility').filter({ hasText: 'selected-python-project' }).waitFor();
|
||||
await page.screenshot({ path: path.join(out, 'desktop-selected.png'), fullPage: true });
|
||||
// Exactly one Install after connection; wait for the real companion job.
|
||||
await page.getByRole('button', { name: 'Install extension' }).click();
|
||||
await page.waitForFunction(() => state.snapshot?.jobId === 1 && !state.snapshot.busy);
|
||||
assert.equal(await page.evaluate(() => state.snapshot.error), undefined);
|
||||
await expectGraph(roots[1], '1.10.0'); assert.deepEqual(await graph(roots[0]), []);
|
||||
checks.push('one browser Install selects 1.10.0 over incompatible 9.0.0 and preview; actual Python graph refreshed only in chosen destination');
|
||||
await page.screenshot({ path: path.join(out, 'desktop-installed.png'), fullPage: true });
|
||||
const config = fs.readFileSync(path.join(roots[1], 'codegraph.json'), 'utf8');
|
||||
const noChoice = await page.evaluate(async () => {
|
||||
try { await rpc('command', { action: 'resolve', id: 'unavailable-demo', project: '1' }); return ''; } catch (e) { return e.message; }
|
||||
});
|
||||
assert.match(noChoice, /No compatible stable release/);
|
||||
await page.evaluate(() => rpc('command', { action: 'install', id: 'unavailable-demo', project: '1' }));
|
||||
await page.waitForFunction(() => state.snapshot?.jobId === 2 && !state.snapshot.busy);
|
||||
assert.match(await page.evaluate(() => state.snapshot.error), /No compatible stable release/);
|
||||
assert.equal(fs.readFileSync(path.join(roots[1], 'codegraph.json'), 'utf8'), config); await expectGraph(roots[1], '1.10.0');
|
||||
await page.evaluate(() => navigate('/extensions/unavailable-demo'));
|
||||
await page.locator('.compatibility').filter({ hasText: 'No compatible stable release' }).waitFor();
|
||||
assert.equal(await page.getByRole('button', { name: 'Install extension' }).isDisabled(), true);
|
||||
checks.push('no-compatible browser selection/command fails usefully and preserves existing config/graph');
|
||||
// Independent CLI destination resolves exactly the same release.
|
||||
await cli(['install', 'compatible-demo', '--registry', registry, '--path', roots[2]]); await expectGraph(roots[2], '1.10.0');
|
||||
const headlessConfig = fs.readFileSync(path.join(roots[2], 'codegraph.json'), 'utf8');
|
||||
const exactFailure = await cli(['install', 'compatible-demo', '--registry', registry, '--version', '9.0.0', '--path', roots[2]], 1);
|
||||
assert.match(exactFailure.stderr, /No compatible exact release 9.0.0/);
|
||||
await cli(['update', 'unavailable-demo', '--registry', registry, '--path', roots[2]], 1);
|
||||
const none = await cli(['install', 'unavailable-demo', '--registry', registry, '--path', roots[2]], 1);
|
||||
assert.match(none.stderr, /No compatible stable release/);
|
||||
assert.equal(fs.readFileSync(path.join(roots[2], 'codegraph.json'), 'utf8'), headlessConfig); await expectGraph(roots[2], '1.10.0');
|
||||
checks.push('headless automatic selection agrees; exact incompatible pin/no-compatible/missing update fail without mutation');
|
||||
await cli(['install', 'compatible-demo', '--registry', registry, '--version', '20.0.0-beta.1', '--path', roots[2]]);
|
||||
await expectGraph(roots[2], '20.0.0-beta.1');
|
||||
const previewConfig = fs.readFileSync(path.join(roots[2], 'codegraph.json'), 'utf8');
|
||||
const downgrade = await cli(['update', 'compatible-demo', '--registry', registry, '--path', roots[2]], 1);
|
||||
assert.match(downgrade.stderr, /would downgrade/);
|
||||
assert.equal(fs.readFileSync(path.join(roots[2], 'codegraph.json'), 'utf8'), previewConfig); await expectGraph(roots[2], '20.0.0-beta.1');
|
||||
await cli(['install', 'compatible-demo', '--registry', registry, '--version', '1.9.0', '--path', roots[2]]); await expectGraph(roots[2], '1.9.0');
|
||||
checks.push('prerelease requires explicit exact pin; automatic update refuses downgrade; explicit lower pin remains exact');
|
||||
await publish('2.0.0', '>=1.6.0 <2', 'compatible-demo', true);
|
||||
await page.reload(); const reconnect = page.waitForEvent('popup');
|
||||
// A named existing popup may be reused: reconnect using a fresh window.
|
||||
await popup.close(); await page.locator('#connect').click(); const second = await reconnect;
|
||||
await second.getByRole('button', { name: 'Allow connection' }).click();
|
||||
await page.getByText('CodeGraph connected', { exact: true }).waitFor();
|
||||
await page.evaluate(() => { state.project = '1'; navigate('/extensions/compatible-demo'); return refreshSelections(); });
|
||||
await page.getByRole('button', { name: 'Update to 2.0.0' }).click();
|
||||
await page.waitForFunction(() => state.snapshot?.jobId === 3 && !state.snapshot.busy);
|
||||
assert.match(await page.evaluate(() => state.snapshot.error), /Extension activation failed/);
|
||||
await expectGraph(roots[1], '1.10.0'); assert.equal(fs.readFileSync(path.join(roots[1], 'codegraph.json'), 'utf8'), config);
|
||||
checks.push('compatible but broken update retains previous version and graph');
|
||||
await publish('3.0.0');
|
||||
await page.evaluate(() => refresh());
|
||||
await page.getByRole('button', { name: 'Update to 3.0.0' }).click();
|
||||
await page.waitForFunction(() => state.snapshot?.jobId === 4 && !state.snapshot.busy);
|
||||
assert.equal(await page.evaluate(() => state.snapshot.error), undefined); await expectGraph(roots[1], '3.0.0');
|
||||
await cli(['update', 'compatible-demo', '--registry', registry, '--path', roots[2]]); await expectGraph(roots[2], '3.0.0');
|
||||
checks.push('browser and headless update to highest compatible stable; stale graph labels removed');
|
||||
// Exact URL and file install remain independent of automatic registry selection.
|
||||
await cli(['install', registry + '/api/download/compatible-demo/1.9.0', '--path', roots[2]]); await expectGraph(roots[2], '1.9.0');
|
||||
const file = path.join(lab, 'pinned.cgext'); fs.writeFileSync(file, pinned.bytes);
|
||||
await cli(['install', file, '--path', roots[2]]); await expectGraph(roots[2], '1.10.0');
|
||||
checks.push('exact artifact URL/file semantics preserved');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.locator('.compatibility').filter({ hasText: 'Selected 3.0.0' }).waitFor();
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
await page.screenshot({ path: path.join(out, 'mobile-selected.png'), fullPage: true });
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click();
|
||||
await page.waitForFunction(() => state.snapshot?.jobId === 5 && !state.snapshot.busy);
|
||||
await cli(['remove', 'compatible-demo', '--path', roots[2]]);
|
||||
assert.deepEqual(await graph(roots[1]), []); assert.deepEqual(await graph(roots[2]), []);
|
||||
assert.deepEqual(errors, []); checks.push('removal cleans contributions; desktop/mobile display actual destination compatibility; no page errors');
|
||||
fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify({ checks, releases, commands, engineVersion: require('../../package.json').version, platform: process.platform, browser: browser.version() }, null, 2));
|
||||
console.log(JSON.stringify({ checks, commands: commands.length }));
|
||||
} catch (error) {
|
||||
await page?.screenshot({ path: path.join(out, 'failure.png'), fullPage: true }).catch(() => {});
|
||||
fs.writeFileSync(path.join(out, 'failure.json'), JSON.stringify({ checks, error: String(error.stack).replace(/token=[a-f0-9]+/g, 'token=[redacted]') }, null, 2)); throw error;
|
||||
} finally { await browser?.close(); await bridge?.close(); await server?.close(); fs.rmSync(lab, { recursive: true, force: true }); }
|
||||
})().catch(error => { console.error(String(error.stack).replace(/token=[a-f0-9]+/g, 'token=[redacted]')); process.exitCode = 1; });
|
||||
@@ -10,7 +10,7 @@ const { startMarketplaceServer } = require('../../dist/plugins/marketplace');
|
||||
const { startExtensionBridge } = require('../../dist/plugins/bridge');
|
||||
process.env.CODEGRAPH_TELEMETRY = '0';
|
||||
process.env.CODEGRAPH_PARSE_WORKERS = '2';
|
||||
const out = path.resolve('.qa/recovery/browser'); fs.mkdirSync(out, { recursive: true });
|
||||
const out = path.resolve(process.env.BROWSER_OUTPUT || '.qa/recovery/browser'); fs.mkdirSync(out, { recursive: true });
|
||||
const checks = [];
|
||||
const safeError = error => String(error.stack || error).replace(/token=[a-f0-9]+/g, 'token=[redacted]');
|
||||
function artifact(version, broken = false) {
|
||||
|
||||
+18
-5
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { ExtensionManager, downloadPackage, type ExtensionProgress } from './manager';
|
||||
import { validateExtensionId } from './releases';
|
||||
import { version } from '../../package.json';
|
||||
|
||||
export interface BridgeHandle { url: string; connectionUrl: string; close(): Promise<void> }
|
||||
@@ -44,7 +45,7 @@ export async function startExtensionBridge(roots: string[], marketplace: string,
|
||||
const supplied = Buffer.from(req.headers.authorization?.replace(/^Bearer /, '') ?? '');
|
||||
if (supplied.length !== token.length || !timingSafeEqual(supplied, Buffer.from(token))) { reply(401, { error: 'Connect CodeGraph first' }); return; }
|
||||
if (req.method === 'GET' && route === '/status') {
|
||||
reply(200, { version, projects: projects.map((p, i) => ({ ...p, extensions: managers[i]!.list() })), progress, jobId, busy: !!job, error: failure }); return;
|
||||
reply(200, { version, apiVersion: 1, projects: projects.map((p, i) => ({ ...p, extensions: managers[i]!.list() })), progress, jobId, busy: !!job, error: failure }); return;
|
||||
}
|
||||
if (req.method !== 'POST' || route !== '/command') { reply(404, { error: 'Unknown command' }); return; }
|
||||
if (job) { reply(409, { error: 'An operation is already running' }); return; }
|
||||
@@ -54,9 +55,18 @@ export async function startExtensionBridge(roots: string[], marketplace: string,
|
||||
const command = JSON.parse(body);
|
||||
const index = projects.findIndex(p => p.id === command.project);
|
||||
if (index < 0) throw new Error('Unknown destination');
|
||||
if (!['install', 'update', 'disable', 'enable', 'remove'].includes(command.action)) throw new Error('Unknown operation');
|
||||
if (!['resolve', 'install', 'update', 'disable', 'enable', 'remove'].includes(command.action)) throw new Error('Unknown operation');
|
||||
if (command.action === 'resolve') {
|
||||
validateExtensionId(command.id);
|
||||
reply(200, await managers[index]!.resolve({ registry: market.origin, id: command.id, version: command.version, update: command.update === true })); return;
|
||||
}
|
||||
if (['install', 'update'].includes(command.action)) {
|
||||
if (typeof command.url !== 'string' || new URL(command.url).origin !== market.origin || !/^[a-f0-9]{64}$/.test(command.integrity)) throw new Error('Expected a pinned release from this marketplace');
|
||||
if (command.url !== undefined) {
|
||||
if (typeof command.url !== 'string' || new URL(command.url).origin !== market.origin || !/^[a-f0-9]{64}$/.test(command.integrity)) throw new Error('Expected a pinned release from this marketplace');
|
||||
} else {
|
||||
validateExtensionId(command.id);
|
||||
if (command.selected !== undefined && (typeof command.selected?.version !== 'string' || !/^[a-f0-9]{64}$/.test(command.selected?.integrity))) throw new Error('Invalid selected release');
|
||||
}
|
||||
if (command.replaces !== undefined && (!Array.isArray(command.replaces) || command.replaces.some((v: unknown) => typeof v !== 'string'))) throw new Error('Invalid replacement list');
|
||||
} else if (typeof command.id !== 'string') throw new Error('Extension id required');
|
||||
const manager = managers[index]!;
|
||||
@@ -64,8 +74,11 @@ export async function startExtensionBridge(roots: string[], marketplace: string,
|
||||
progress = { state: 'downloading', message: 'Preparing extension operation' };
|
||||
job = (async () => {
|
||||
if (command.action === 'install' || command.action === 'update') {
|
||||
const bytes = await downloadPackage(command.url, market.origin);
|
||||
await manager.install({ bytes, integrity: command.integrity, source: command.url, replaces: command.replaces });
|
||||
if (command.url !== undefined) {
|
||||
const bytes = await downloadPackage(command.url, market.origin);
|
||||
await manager.install({ bytes, integrity: command.integrity, source: command.url, replaces: command.replaces });
|
||||
} else await manager.installFromRegistry({ registry: market.origin, id: command.id, version: command.version,
|
||||
update: command.action === 'update', selected: command.selected, replaces: command.replaces });
|
||||
} else if (command.action === 'remove') await manager.remove(command.id);
|
||||
else await manager.setEnabled(command.id, command.action === 'enable');
|
||||
})().catch(err => { failure = String(err); progress = { state: 'failed', message: failure }; }).finally(() => { job = undefined; });
|
||||
|
||||
+11
-2
@@ -23,12 +23,21 @@ export function registerExtensionCommands(program: Command): void {
|
||||
console.log(JSON.stringify(new ExtensionManager(options.path).list(), null, 2));
|
||||
});
|
||||
for (const action of ['install', 'update']) extensions.command(`${action} <package>`)
|
||||
.description('Install a .cgext package or HTTPS release; extension code runs with your permissions')
|
||||
.description('Install an exact .cgext file/URL, or resolve an id with --registry; extension code runs with your permissions')
|
||||
.option('--path <path>', 'Project directory', '.')
|
||||
.option('--registry <url>', 'Resolve the package argument as an extension id from this registry')
|
||||
.option('--version <version>', 'Exact registry version (required to opt into a prerelease); never substitutes')
|
||||
.option('--replaces <frameworks>', 'Explicitly replace comma-separated built-in resolvers')
|
||||
.action(async (source, options) => {
|
||||
const manager = new ExtensionManager(options.path, p => console.error(`${p.state}: ${p.message}`));
|
||||
if (options.registry) {
|
||||
const installed = await manager.installFromRegistry({ registry: options.registry, id: source, version: options.version,
|
||||
update: action === 'update', replaces: options.replaces?.split(',') });
|
||||
console.log(`Installed ${source} ${installed.version}`); return;
|
||||
}
|
||||
if (options.version) throw new Error('--version requires --registry; file and URL installs already select an exact artifact');
|
||||
const bytes = /^https?:/.test(source) ? await downloadPackage(source) : fs.readFileSync(path.resolve(source));
|
||||
await new ExtensionManager(options.path, p => console.error(`${p.state}: ${p.message}`)).install({ bytes,
|
||||
await manager.install({ bytes,
|
||||
source: /^https?:/.test(source) ? source : undefined, replaces: options.replaces?.split(',') });
|
||||
});
|
||||
for (const action of ['disable', 'enable', 'remove']) extensions.command(`${action} <id>`)
|
||||
|
||||
+36
-1
@@ -6,9 +6,17 @@ import { clearProjectConfigCache, loadPluginEntries } from '../project-config';
|
||||
import { packageDigest, parsePackage, sha256, MAX_PACKAGE_BYTES } from './package';
|
||||
import { pluginDirectory } from './loader';
|
||||
import type { PluginEntry } from './api';
|
||||
import * as semver from 'semver';
|
||||
import { resolveRelease, validateExtensionId, type ExtensionRelease } from './releases';
|
||||
|
||||
export interface ExtensionProgress { state: 'downloading' | 'installing' | 'indexing' | 'ready' | 'failed'; message: string }
|
||||
export interface InstallRequest { bytes: Buffer; source?: string; integrity?: string; replaces?: string[] }
|
||||
export interface InstallRequest { bytes: Buffer; source?: string; integrity?: string; replaces?: string[]; expected?: { id: string; version: string }; automatic?: boolean }
|
||||
|
||||
export interface RegistryRequest {
|
||||
registry: string; id: string; version?: string; update?: boolean; replaces?: string[];
|
||||
/** Optional preview binding: refuse a catalog change after the user saw a selection. */
|
||||
selected?: { version: string; integrity: string };
|
||||
}
|
||||
|
||||
export function atomicWrite(file: string, data: string): void {
|
||||
const temp = file + '.' + randomUUID() + '.tmp';
|
||||
@@ -39,13 +47,40 @@ export class ExtensionManager {
|
||||
}
|
||||
list(): PluginEntry[] { return loadPluginEntries(this.root); }
|
||||
|
||||
async resolve(request: RegistryRequest): Promise<ExtensionRelease> {
|
||||
validateExtensionId(request.id);
|
||||
const current = this.list().find(e => e.name === `managed:${request.id}`);
|
||||
if (request.update && !current) throw new Error(`${request.id} is not installed in this destination`);
|
||||
const registry = new URL(request.registry);
|
||||
if (registry.username || registry.password) throw new Error('Registry URL must not contain credentials');
|
||||
const url = new URL(`/api/extensions/${request.id}`, registry);
|
||||
const raw = JSON.parse((await downloadPackage(url.href)).toString('utf8'));
|
||||
return resolveRelease(raw, request.id, { engineVersion, version: request.version,
|
||||
currentVersion: request.version === undefined ? current?.version : undefined });
|
||||
}
|
||||
|
||||
async installFromRegistry(request: RegistryRequest): Promise<PluginEntry> {
|
||||
this.onProgress({ state: 'downloading', message: `Resolving ${request.id} for CodeGraph ${engineVersion} (API 1)` });
|
||||
const release = await this.resolve(request);
|
||||
if (request.selected && (request.selected.version !== release.version || request.selected.integrity !== release.integrity)) throw new Error('Compatible release changed since selection. Refresh the marketplace and try again.');
|
||||
const url = new URL(`/api/download/${request.id}/${encodeURIComponent(release.version)}`, request.registry);
|
||||
const bytes = await downloadPackage(url.href);
|
||||
return this.install({ bytes, source: url.href, integrity: release.integrity, replaces: request.replaces,
|
||||
expected: { id: request.id, version: release.version }, automatic: request.version === undefined });
|
||||
}
|
||||
|
||||
async install(request: InstallRequest): Promise<PluginEntry> {
|
||||
const p = parsePackage(request.bytes, engineVersion);
|
||||
if (request.expected && (p.package.codegraph.id !== request.expected.id || p.package.version !== request.expected.version)) throw new Error('Downloaded extension identity/version does not match the selected release');
|
||||
const integrity = sha256(request.bytes);
|
||||
if (request.integrity && request.integrity !== integrity) throw new Error('Downloaded extension integrity does not match its release');
|
||||
const entry: PluginEntry = { name: `managed:${p.package.codegraph.id}`, version: p.package.version, integrity,
|
||||
enabled: true, replaces: request.replaces ?? [], ...(request.source ? { source: request.source } : {}) };
|
||||
await this.change(async entries => {
|
||||
// Recheck under the operation lock: another completed install may have
|
||||
// advanced the destination while registry metadata was being fetched.
|
||||
const installed = entries.find(e => e.name === entry.name);
|
||||
if (request.automatic && installed?.version && (!semver.valid(installed.version) || semver.lt(p.package.version, installed.version))) throw new Error(`Automatic install would downgrade installed ${installed.version}; request an exact version explicitly`);
|
||||
this.onProgress({ state: 'installing', message: `Installing ${p.package.codegraph.id} ${p.package.version}` });
|
||||
const packages = path.join(pluginDirectory(this.root), 'packages');
|
||||
fs.mkdirSync(packages, { recursive: true, mode: 0o700 });
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import * as http from 'node:http';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as semver from 'semver';
|
||||
import { createPublicKey, verify, type JsonWebKey } from 'node:crypto';
|
||||
import { createDatabase } from '../db/sqlite-adapter';
|
||||
import { parsePackage, sha256, MAX_PACKAGE_BYTES } from './package';
|
||||
@@ -13,7 +14,7 @@ interface Submission {
|
||||
}
|
||||
interface Listing {
|
||||
id: string; name: string; description: string; publisher: string; publisherId: string;
|
||||
official: boolean; version: string; engines: string; capabilities: string[]; integrity: string;
|
||||
official: boolean; version: string; apiVersion: number; engines: string; capabilities: string[]; integrity: string;
|
||||
readme: string; source: string; publishedAt: string;
|
||||
}
|
||||
export function createMarketplaceStore(database: string) {
|
||||
@@ -27,7 +28,7 @@ export function createMarketplaceStore(database: string) {
|
||||
const id = pkg.codegraph.id;
|
||||
const listing: Listing = { id, version: pkg.version, name: details.name, description: details.description,
|
||||
publisherId: details.publisherId, publisher: details.publisher, official: details.official === true,
|
||||
readme: details.readme, source: details.source, engines: pkg.codegraph.engines ?? '*',
|
||||
readme: details.readme, source: details.source, apiVersion: pkg.codegraph.apiVersion, engines: pkg.codegraph.engines ?? '*',
|
||||
capabilities: pkg.codegraph.capabilities, integrity: sha256(bytes), publishedAt: new Date().toISOString() };
|
||||
db.transaction(() => {
|
||||
const owner = db.prepare('SELECT publisher FROM extensions WHERE id = ?').get(id) as { publisher: string } | undefined;
|
||||
@@ -37,6 +38,17 @@ export function createMarketplaceStore(database: string) {
|
||||
})();
|
||||
return listing;
|
||||
}
|
||||
// Old persisted listings predate apiVersion. Recover it from their immutable
|
||||
// package, never guess that a missing API marker is supported.
|
||||
function listings(id?: string): Listing[] {
|
||||
const rows = (id === undefined ? db.prepare('SELECT listing, bytes FROM releases').all() :
|
||||
db.prepare('SELECT listing, bytes FROM releases WHERE id=?').all(id)) as { listing: string; bytes: Uint8Array }[];
|
||||
return rows.map(row => {
|
||||
const listing = JSON.parse(row.listing) as Listing;
|
||||
if (listing.apiVersion === undefined) listing.apiVersion = parsePackage(Buffer.from(row.bytes)).package.codegraph.apiVersion;
|
||||
return listing;
|
||||
}).sort((a, b) => Number(!!semver.prerelease(a.version)) - Number(!!semver.prerelease(b.version)) || semver.rcompare(a.version, b.version) || a.version.localeCompare(b.version));
|
||||
}
|
||||
return {
|
||||
close: () => db.close(),
|
||||
seedOfficial(bytes: Buffer): void {
|
||||
@@ -48,12 +60,11 @@ export function createMarketplaceStore(database: string) {
|
||||
readme: 'Understand the framework connections that ordinary function calls cannot show.\n\nRoutes and forms connect to their handlers. Service definitions connect to implementations and explicit injected services. Documented procedural hooks and Hook attributes connect to literal invocations. Plugin annotations and attributes identify implementations. Literal event dispatch connects to declared subscribers.\n\nInstall replaces the built-in Drupal resolver for this project. Requires CodeGraph 1.6.0 with extension support (preview build).\n\nComputed identifiers, external dependencies outside your index, ambiguous classes and unknown entity handlers remain unresolved. New programming languages and PHP branch-condition analysis are outside this extension API.' });
|
||||
},
|
||||
list(): Listing[] {
|
||||
const rows = db.prepare('SELECT listing FROM releases ORDER BY created DESC').all() as { listing: string }[];
|
||||
const seen = new Set<string>();
|
||||
return rows.map(r => JSON.parse(r.listing) as Listing).filter(l => { if (seen.has(l.id)) return false; seen.add(l.id); return true; });
|
||||
return listings().filter(l => { if (seen.has(l.id)) return false; seen.add(l.id); return true; });
|
||||
},
|
||||
releases(id: string): Listing[] {
|
||||
return (db.prepare('SELECT listing FROM releases WHERE id=? ORDER BY created DESC').all(id) as { listing: string }[]).map(r => JSON.parse(r.listing));
|
||||
return listings(id);
|
||||
},
|
||||
download(id: string, version: string): Buffer | undefined {
|
||||
const row = db.prepare('SELECT bytes FROM releases WHERE id=? AND version=?').get(id, version) as { bytes: Uint8Array } | undefined;
|
||||
@@ -99,7 +110,7 @@ export async function startMarketplaceServer(options: { database: string; public
|
||||
if (req.method === 'GET' && release) { json(200, store.releases(release[1]!)); return; }
|
||||
const download = /^\/api\/download\/([a-z0-9-]+)\/([^/]+)$/.exec(url.pathname);
|
||||
if (req.method === 'GET' && download) {
|
||||
const bytes = store.download(download[1]!, download[2]!);
|
||||
const bytes = store.download(download[1]!, decodeURIComponent(download[2]!));
|
||||
if (!bytes) { json(404, { error: 'Release not found' }); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'application/vnd.codegraph.extension+json', 'Content-Length': bytes.length,
|
||||
'Cache-Control': 'public, max-age=31536000, immutable', 'Content-Disposition': `attachment; filename="${download[1]}-${download[2]}.cgext"` });
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as semver from 'semver';
|
||||
import { validateManifest } from './package';
|
||||
|
||||
/** Catalog metadata is only a selection hint. The installer validates the artifact again. */
|
||||
export interface ExtensionRelease {
|
||||
id: string; version: string; apiVersion: number; engines: string;
|
||||
capabilities: string[]; integrity: string;
|
||||
}
|
||||
export interface ReleaseSelection {
|
||||
engineVersion: string;
|
||||
/** Exact pins, including prereleases, never fall back to another release. */
|
||||
version?: string;
|
||||
/** Automatic selection cannot replace an installed version with an older release. */
|
||||
currentVersion?: string;
|
||||
}
|
||||
export function validateExtensionId(id: unknown): asserts id is string {
|
||||
if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(id)) throw new Error('Expected a lowercase extension id');
|
||||
}
|
||||
export function resolveRelease(raw: unknown, id: string, options: ReleaseSelection): ExtensionRelease {
|
||||
validateExtensionId(id);
|
||||
if (!semver.valid(options.engineVersion)) throw new Error('Cannot determine the connected CodeGraph version');
|
||||
if (options.version !== undefined && (typeof options.version !== 'string' || semver.valid(options.version) !== options.version)) throw new Error('Request an exact semantic version, such as 1.2.3 (prereleases require an exact pin)');
|
||||
if (!Array.isArray(raw)) throw new Error('Registry returned an invalid release catalog');
|
||||
if (options.currentVersion !== undefined && !semver.valid(options.currentVersion)) throw new Error('Installed version is invalid; request an explicit version to repair it');
|
||||
const candidates: ExtensionRelease[] = [], reasons: string[] = [];
|
||||
const versions = new Set<string>();
|
||||
for (const item of raw) {
|
||||
const r = item as ExtensionRelease;
|
||||
if (options.version !== undefined && r?.version !== options.version) continue;
|
||||
try {
|
||||
if (!r || r.id !== id || typeof r.version !== 'string' || semver.valid(r.version) !== r.version ||
|
||||
typeof r.integrity !== 'string' || !/^[a-f0-9]{64}$/.test(r.integrity) || typeof r.engines !== 'string') throw new Error('invalid release identity, version, integrity or engine range');
|
||||
if (versions.has(r.version)) throw new Error(`Registry returned duplicate immutable version ${r.version}`);
|
||||
versions.add(r.version);
|
||||
validateManifest({ id: r.id, apiVersion: r.apiVersion, capabilities: r.capabilities, engines: r.engines }, options.engineVersion);
|
||||
if (options.version === undefined && semver.prerelease(r.version)) throw new Error('prerelease requires an exact --version pin');
|
||||
if (options.version === undefined && options.currentVersion && semver.lt(r.version, options.currentVersion)) throw new Error(`would downgrade installed ${options.currentVersion}`);
|
||||
candidates.push(r);
|
||||
} catch (error) { reasons.push(`${r?.version ?? 'unknown'}: ${error instanceof Error ? error.message : String(error)}`); }
|
||||
}
|
||||
// A duplicate cannot be resolved safely by publication order or array order.
|
||||
if (reasons.some(r => r.includes('duplicate immutable version'))) throw new Error(reasons.find(r => r.includes('duplicate immutable version')));
|
||||
candidates.sort((a, b) => semver.rcompare(a.version, b.version) || a.version.localeCompare(b.version));
|
||||
if (candidates.length) return candidates[0]!;
|
||||
const requested = options.version === undefined ? 'compatible stable release' : `compatible exact release ${options.version}`;
|
||||
throw new Error(`No ${requested} for ${id} on CodeGraph ${options.engineVersion} (API 1)${options.currentVersion ? `; installed ${options.currentVersion} is unchanged` : ''}. ${reasons.slice(0, 8).join('; ') || 'Release not found.'}`);
|
||||
}
|
||||
Reference in New Issue
Block a user