From b8fe14e605ba4211e112f93b9c2e14ec52ea5bb4 Mon Sep 17 00:00:00 2001
From: Lan_zhijiang
Date: Tue, 13 Oct 2026 16:13:27 +0800
Subject: [PATCH 01/27] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E7=BB=9F?=
=?UTF-8?q?=E4=B8=80=20Extension=20Release=20=E7=BB=84=E8=A3=85=E5=A5=91?=
=?UTF-8?q?=E7=BA=A6=E5=BC=95=E7=94=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/_shared | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/_shared b/docs/_shared
index 7916312c..2031cf0f 160000
--- a/docs/_shared
+++ b/docs/_shared
@@ -1 +1 @@
-Subproject commit 7916312c12cfaf2ee396d7dc1afffee67d4f0721
+Subproject commit 2031cf0f453ed694d9736477258bd798af64ac54
From fafa241915f5ae0577d27462fa35b6616d572cd8 Mon Sep 17 00:00:00 2001
From: Lan_zhijiang
Date: Tue, 13 Oct 2026 16:14:43 +0800
Subject: [PATCH 02/27] =?UTF-8?q?refactor(release):=20Web=20=E5=8E=9F?=
=?UTF-8?q?=E7=94=9F=E4=BA=A7=E7=89=A9=E6=B6=88=E8=B4=B9=E7=BB=9F=E4=B8=80?=
=?UTF-8?q?=20Extension=20=E5=8F=91=E5=B8=83=E8=AE=A1=E5=88=92?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.changeset/README.md | 7 +-
.changeset/config.json | 2 +-
.changeset/schema-forms-tabs.md | 3 -
.github/workflows/ci.yml | 9 +-
.github/workflows/package-release.yml | 57 +++
ARCHITECTURE.md | 4 +-
docs/30-unit-tdd/native-extension-runtime.md | 4 +-
.../native-extension-delivery.md | 170 +++------
extensions/AGENTS.md | 8 +-
scripts/build-native-extension-candidates.mjs | 122 ++++++
scripts/publish-first-party-extensions.mjs | 350 ------------------
scripts/test-extension-publication.mjs | 209 -----------
scripts/test-native-extension-candidates.mjs | 72 ++++
13 files changed, 318 insertions(+), 699 deletions(-)
create mode 100644 .github/workflows/package-release.yml
create mode 100644 scripts/build-native-extension-candidates.mjs
delete mode 100644 scripts/publish-first-party-extensions.mjs
delete mode 100644 scripts/test-extension-publication.mjs
create mode 100644 scripts/test-native-extension-candidates.mjs
diff --git a/.changeset/README.md b/.changeset/README.md
index caf5b89c..dee7d8d3 100644
--- a/.changeset/README.md
+++ b/.changeset/README.md
@@ -1,5 +1,6 @@
# Changesets
-Each user-visible Extension change carries a small Markdown file that declares the affected
-Extension package and SemVer impact. Run `pnpm changeset` to create one. The automated Version PR
-consumes these files and updates package versions and changelogs; it does not publish packages.
+Application and SDK changes use `pnpm changeset`. The package Version PR consumes these files and
+updates their versions and changelogs; it does not publish Extensions. Native producers under
+`extensions/` are excluded: their shared Release version comes from core-py. Record native producer
+changes in the corresponding core-py Extension release fragment before selecting its Web revision.
diff --git a/.changeset/config.json b/.changeset/config.json
index 36b43819..406f683d 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -7,7 +7,7 @@
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
- "ignore": [],
+ "ignore": ["@inkcre/ext-mail", "@inkcre/ext-memos", "@inkcre/ext-twitter"],
"privatePackages": {
"version": true,
"tag": false
diff --git a/.changeset/schema-forms-tabs.md b/.changeset/schema-forms-tabs.md
index 01d5bf10..af222f8c 100644
--- a/.changeset/schema-forms-tabs.md
+++ b/.changeset/schema-forms-tabs.md
@@ -2,9 +2,6 @@
'@inkcre/client-web': patch
'@inkcre/core': patch
'@inkcre/ext-dev-utils': patch
-'@inkcre/ext-mail': patch
-'@inkcre/ext-memos': patch
-'@inkcre/ext-twitter': patch
---
Source 与 Peer 配置使用可视化表单,保留完整 JSON 编辑模式;Peers 页面可确认删除离线 Peer 并清理其扩展启用记录。Extension 浏览与版本选择交由 Registry,Web 接收精确版本并确认安装;Web 与 Module Federation 扩展统一升级正式 UI 2.2.0,Peer AI 凭据在 schema 中标记为密码输入。
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9baa557f..96f62b15 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,7 +41,7 @@ jobs:
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 .github/workflows/*.yml
core-release:
- name: Resolve stable core release
+ name: Resolve admitted Core release
runs-on: ubuntu-latest
outputs:
contract_revision: ${{ steps.release.outputs.contract_revision }}
@@ -64,7 +64,10 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - name: Resolve stable once to an immutable release
+ - name: Verify canonical candidate admission boundaries
+ run: node --test scripts/test-core-release.mjs
+
+ - name: Resolve admitted Core once to an immutable release
id: release
run: node scripts/resolve-core-release.mjs
@@ -121,7 +124,7 @@ jobs:
--core-package packages/core/package.json
--artifact-directory extensions/twitter/dist/client-web
- - name: Verify publication recovery over HTTP
+ - name: Verify independent MF publication
run: node --test scripts/test-extension-publication.mjs scripts/test-core-sdk-publication.mjs
peer-database:
diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml
new file mode 100644
index 00000000..b02e41dc
--- /dev/null
+++ b/.github/workflows/package-release.yml
@@ -0,0 +1,57 @@
+name: Package version planning
+
+on:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+concurrency:
+ group: package-version-main
+ cancel-in-progress: false
+
+jobs:
+ reconcile:
+ name: Reconcile application and SDK versions
+ permissions:
+ contents: write
+ packages: read
+ pull-requests: write
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout main
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+
+ - name: Install the pinned pnpm release
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
+ with:
+ version: 11.11.0
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: package.json
+ cache: pnpm
+ registry-url: https://npm.pkg.github.com
+ scope: '@inkcre'
+
+ - name: Install the frozen workspace
+ run: pnpm install --frozen-lockfile
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Create or update the Package Version PR
+ id: changesets
+ uses: changesets/action@8488615a623b1b9c987934bb89eae8af6a946ac1 # v2.1.1
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ version-script: pnpm release:version
+ pr-title: 'chore(packages): version pending releases'
+ commit-message: 'chore(packages): version pending releases'
+ pr-base-branch: main
+ create-github-releases: false
+ push-git-tags: false
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 9746ff32..54b40727 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,7 +1,7 @@
# Repository Architecture
This monorepo implements three logical client-side units across shared packages, applications, and
-independently versioned Extension producers.
+independently built Extension producers.
```text
Vue web application -----------+
@@ -41,7 +41,7 @@ locations, not Unit boundaries.
configuration/authentication, and Extension Host.
- `apps/client-web/` - Vue SPA, graph/list surfaces, settings, and static Vite artifact.
- `apps/client-webext/` - WXT browser surfaces and content scripts.
-- `extensions/` - independently versioned native Module Federation producers.
+- `extensions/` - independently built native Module Federation producers.
- `packages/ext-dev-utils/` - producer development support consumed by applications.
The package graph is ESM-first. Workspace Vite and WXT applications consume `@inkcre/core` source
diff --git a/docs/30-unit-tdd/native-extension-runtime.md b/docs/30-unit-tdd/native-extension-runtime.md
index 886e819e..a3e94fcc 100644
--- a/docs/30-unit-tdd/native-extension-runtime.md
+++ b/docs/30-unit-tdd/native-extension-runtime.md
@@ -3,7 +3,7 @@
## Boundary
The native Extension Runtime spans the browser Web Extension Host, its SDK-owned durable models, and
-independently versioned Module Federation producers. It owns compatibility preflight, executable
+independently built Module Federation producers. It owns compatibility preflight, executable
loading, runtime lifecycle, and reconciliation with durable enabled intent. Release publication,
Registry delivery, and deployment workflow remain outside this Unit TDD; cross-unit Extension and
Registry contracts remain in [`../_shared/`](../_shared/).
@@ -20,7 +20,7 @@ manifest.
## Producer and Host Contract
-Each producer is an independently versioned package. Its default export preserves the optional
+Each producer is an independent build package; the unified Extension release plan supplies its version. Its default export preserves the optional
native lifecycle methods `initialize`, `activate`, `deactivate`, and `dispose`; host-consumed
capabilities come from its package entry rather than playground bootstrap code. Extension code
imports `@inkcre/core` directly.
diff --git a/docs/40-deployment/native-extension-delivery.md b/docs/40-deployment/native-extension-delivery.md
index 32c96e38..00e4fc69 100644
--- a/docs/40-deployment/native-extension-delivery.md
+++ b/docs/40-deployment/native-extension-delivery.md
@@ -1,131 +1,55 @@
# First-party Extension Delivery
-This document owns the operational Release and Registry delivery contract for first-party client-web
-Extensions. Extension lifecycle and Host internals belong to
-[Native Extension Runtime](../30-unit-tdd/native-extension-runtime.md). A producer is an
-`extensions/*/package.json` declaring `inkcre.module_federation`; its package version is the
-exact Registry Release version, and its `inkcre` metadata owns the canonical Extension identity and
-compatible `@inkcre/core` Host SDK range.
+This document owns Web native build delivery. Host runtime contracts belong to
+[Native Extension Runtime](../30-unit-tdd/native-extension-runtime.md). Each `extensions/*/package.json`
+with `inkcre.module_federation` owns its canonical identity, nickname and compatible `@inkcre/core`
+range. Its committed package version is development metadata; the Core release plan generates the
+exact shared Release version in an isolated build checkout.
-## Release Intent and Versioning
+## One Release Decision
-Contributors record intent with `pnpm changeset`, selecting only affected independently releasable
-Extension packages. On a protected `main` push,
-[`.github/workflows/extension-release.yml`](../../.github/workflows/extension-release.yml) runs the
-**First-party Extension release** lifecycle:
+Core's first-party Extension release workflow owns version selection, fixed Core and Web source
+revisions, candidate retention and Registry publication. A native Web change requires a release
+fragment for the corresponding Extension in core-py. Select the Web source revision containing that
+change when assembling the Release. App and SDK packages continue using Changesets and the package
+Version PR; native producers are excluded from that version decision.
-- With pending changesets, Changesets Action runs `pnpm release:version` and creates or updates the
- Extension Version PR. That PR owns generated package versions and changelogs; contributors do not
- edit those outputs separately. The reconciliation step does not publish, create GitHub Releases,
- or push tags.
-- After the Version PR is merged and no changesets remain, the candidate job checks out that exact
- release revision, installs the frozen workspace, and builds `@inkcre/core` plus every
- `extensions/*` producer itself. It saves the selected plan, original prepare descriptors, full ZIP
- snapshots and SHA-256 digests as the immutable `first-party-extension-candidate` Actions artifact.
- No Registry writes occur until that artifact is retained and checked.
-- Producers are discovered from package metadata. A published Release with an existing Module
- Federation association is a historical no-op at candidate selection. Missing associations become
- fixed candidates; the publish job downloads this run's exact artifact, prepares them with their
- saved provenance, uploads, publishes, and verifies every candidate from the public Registry.
- It never consumes pull-request CI artifacts or rebuilds a publication candidate.
-
-The repository must enable **Allow GitHub Actions to create and approve pull requests** for
-Changesets to create its Version PR. GitHub combines these capabilities in one setting; this
-workflow only creates PRs and must never approve reviews. Default workflow-token permissions stay
-read-only, with write permissions confined to reconciliation. GitHub may require a maintainer to
-select **Approve workflows to run** on a token-created Version PR before its checks execute; do not
-replace this gate with an empty-check merge or a broader credential.
-
-Client checks, Pages production, and Pages preview are separate application lifecycles. They cannot
-publish first-party Extensions. Local development and local verification must never publish either.
-
-Python and Module Federation producers for one Extension must target the same exact Release when
-both Hosts are needed. A published Python-only Release may receive its missing Module Federation
-association through the normal publish workflow; existing associations are not overwritten. Select
-the Changesets increment that reaches that Release rather than downgrading the shared installation
-or manually editing generated package versions.
-
-## Registry Authority and Secret Boundary
-
-Publication runs only in the protected GitHub `production` environment. The scoped bearer secret
-`INKCRE_EXTENSION_REGISTRY_TOKEN` belongs only to the first-party Extension publish job; it must not be
-copied to a file, exposed to Pages or checks, or used for protected delivery from a developer
-machine. The public Registry defaults to `https://registry.inkcre.dev`; operators may override only
-the endpoint through repository variable `INKCRE_EXTENSION_REGISTRY_URL`.
-
-The workflow records `source_repository`, `source_revision`, and a release-workflow `build_id` in
-the Module Federation distribution before upload. These Web-distribution provenance facts remain
-independent of any Python distribution attached to the same Registry Release.
-
-Hosted documentation is discovered separately for the exact Release. Browser code follows the
-absolute entry or snapshot URL returned by Registry and never derives a content hostname from the
-Registry origin, Extension identity, or snapshot ID. The first-party deployment currently uses a
-broad `*.inkcre.dev` DNS/TLS catch-all while Registry admits only
-`registry-docs-{snapshot}.inkcre.dev`; therefore DNS resolution or a successful TLS handshake is
-not documentation availability. A 421 response is a rejected Host, not a URL pattern for the Web
-client to repair or retry under another subdomain.
-
-## Failure and Recovery
-
-An upload timeout is an unknown outcome, not proof of a rejected snapshot: a Registry behind Heroku
-may finish after its router returns H12. The publisher makes a bounded attempt to complete the
-existing publish operation and read back the Release. A new MF-only Release remains publicly
-invisible while preparing, so recovery cannot rely on public GET alone. Authentication failures,
-immutable conflicts, blocked/yanked Releases and byte mismatches fail rather than becoming retries
-or successful skips.
-
-After a transient publication failure, use **Re-run failed jobs** or re-run only
-**Publish first-party MF distributions**. These partial reruns retain the successful candidate
-job's artifact and output, so publication downloads the same exact artifact ID. Do not use
-**Re-run all jobs**: GitHub removes the original artifacts before executing the new attempt.
-Candidate discovery rejects a missing artifact on any attempt after the first, before build or
-Registry writes. An existing candidate is restored and checked, never silently replaced.
-Both first publication and recovery verify the full public
-snapshot, including candidates whose association appeared after an earlier timeout. A later,
-unrelated main push may classify those historical Releases as no-ops; it does not claim to verify
-them against a new build.
-
-Candidates are retained for 90 days. Do not delete the artifact of an unfinished release or start
-a new run to replace its prepared provenance. An expired or lost candidate requires operator
-investigation using the original run and Registry state; a fresh build is not equivalent evidence.
-The same conservative stop applies if the first attempt failed before saving any candidate. After
-confirming that publication never ran, the operator may proceed through a new normal main run.
-If publication did run, recover the original candidate; do not use a new build or new run to replace
-its bytes or prepared provenance.
-The original ZIP digest and all-file verification count are written to the publish job summary.
-
-`node --test scripts/test-extension-publication.mjs` exercises the publisher over an isolated HTTP
-fault-injection endpoint: an MF-only upload returns 503 while the saved snapshot becomes available,
-publish initially reports incomplete objects, and recovery verifies the original ZIP even after
-local build output changes. It also proves that an existing selected association cannot bypass
-byte verification, immutable conflicts stop immediately, and invalid saved candidates cause no
-Registry writes. It also checks that a rerun without its saved candidate stops before rebuilding.
-This is a publisher regression check, not evidence of production R2 latency.
-
-## Snapshot and Verification
-
-Each producer retains the relative artifact base `base: './'`, emits
-`dist/client-web/mf-manifest.json`, and declares its Registry association in package metadata. The
-distribution verifier checks the manifest Remote entry and every synchronous or asynchronous
-shared/exposed JavaScript and CSS reference, then compares every file in the uploaded snapshot,
-including transitive chunks and generated metadata. The uploaded snapshot is self-contained and
-relocatable; publication does not invent a generic target descriptor or rewrite the producer
-manifest.
-
-Public executable responses use `Cache-Control: public, no-cache` and an ETag. Immutable Release
-bytes do not imply an immutable HTTP cache policy: browsers must revalidate Registry readability
-so a blocked Release stops being served. Verification also checks CORS, the trusted Registry origin,
-manifest structure, and the exact bytes of every referenced asset.
-
-The executable contract is
-[`scripts/verify-native-extension-distribution.mjs`](../../scripts/verify-native-extension-distribution.mjs).
-Verify locally without publishing:
+The Core workflow invokes the Web consumer after checking out its fixed source revision:
```bash
-pnpm --filter @inkcre/core build
-pnpm --filter './extensions/*' build
-node scripts/verify-native-extension-distribution.mjs inspect-local \
- --package extensions//package.json \
- --core-package packages/core/package.json \
- --artifact-directory extensions//dist/client-web
+node scripts/build-native-extension-candidates.mjs consume-plan \
+ --plan /absolute/path/to/plan.json --output /absolute/path/to/candidate
```
+
+The plan contains `web_revision`, `build_id` and `candidates` selected by Core. Each candidate includes
+`directory`, canonical `name`, `version` and the Python `prepare` association. The consumer matches
+producer metadata by canonical name, generates all matching package versions, builds `@inkcre/core`
+and the selected MF producers, and checks the resulting snapshots. A candidate with no Web producer
+remains Python-only. A matching producer that cannot build or validate fails the whole assembly.
+
+The output `plan.json` retains Core's fields and adds `prepare.module_federation` and
+`module_federation: {file, sha256}` to matching candidates. ZIP paths are relative to the retained
+candidate directory. Provenance records the fixed Web SHA and Core workflow build ID. Existing
+candidate bytes cannot be overwritten by another capture.
+
+Core adds checked wheels, retains the complete candidate before any Registry write, prepares all
+associations once, uploads both formats and publishes once. Recovery restores that exact retained
+candidate rather than rebuilding it. Registry credentials belong only to the protected Core publish
+job. Web CI, Pages production, Pages preview and this local consumer never publish Extensions.
+
+## Snapshot Verification
+
+Producers retain `base: './'` and emit `dist/client-web/mf-manifest.json`. The existing
+[`distribution verifier`](../../scripts/verify-native-extension-distribution.mjs) checks the Remote
+entry, shared/exposed JavaScript and CSS references, Host SDK range and all snapshot files. Public
+verification compares every uploaded file, including transitive chunks and generated metadata,
+checks CORS, and confirms that Registry changes only the manifest's public path.
+
+Public executable responses use `Cache-Control: public, no-cache` and an ETag so browsers revalidate
+Registry readability when a Release is blocked. Hosted documentation is discovered independently
+for the exact Release; browser clients follow the returned absolute URL and never invent a hostname.
+
+`node --test scripts/test-native-extension-candidates.mjs` checks shared version generation, candidate
+capture, Python-only preservation, saved-candidate protection and provenance over an isolated fixture.
+This does not establish full Client Web enablement; that requires a real published preview Release
+and browser acceptance against the preview Registry.
diff --git a/extensions/AGENTS.md b/extensions/AGENTS.md
index 6481722b..65189cfd 100644
--- a/extensions/AGENTS.md
+++ b/extensions/AGENTS.md
@@ -8,15 +8,17 @@ Host/producer runtime contracts belong to
## Local Tripwires
-- Keep each directory an independently versioned package. Its directory name is the local ID;
+- Keep each directory an independent build package. Its directory name is the local ID;
`package.json#inkcre.name`, version, nickname, and `module_federation` association are Registry
identity and compatibility inputs, not playground metadata.
- Keep production remotes relocatable: `vite.config.ts` must retain `base: './'`, emit the native
`mf-manifest.json`, and write `dist/client-web`.
- Export host-consumed capabilities from `src/index.ts`; keep `src/main.ts` limited to the local
playground. Do not make the Host depend on playground bootstrap behavior.
-- Changes to a native producer must preserve the asset-closure validation and select only affected
- independently releasable packages in `pnpm changeset`.
+- Changes to a native producer must preserve the asset-closure validation. Record its Release intent
+ in the corresponding core-py Extension release fragment; do not select it in Changesets.
+- The unified Core release plan generates native package versions in the isolated build checkout.
+ Committed native package versions are development metadata, not a second Release authority.
## Required Check
diff --git a/scripts/build-native-extension-candidates.mjs b/scripts/build-native-extension-candidates.mjs
new file mode 100644
index 00000000..1082d1f7
--- /dev/null
+++ b/scripts/build-native-extension-candidates.mjs
@@ -0,0 +1,122 @@
+import assert from 'node:assert/strict'
+import { execFileSync } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { valid as validSemVer } from 'semver'
+import {
+ inspectNativeModuleFederation,
+ listSnapshotFiles,
+ prepareBody,
+} from './verify-native-extension-distribution.mjs'
+
+const readJson = async (file) => JSON.parse(await readFile(file, 'utf8'))
+const writeJson = (file, value) => writeFile(file, `${JSON.stringify(value, null, 2)}\n`)
+
+export async function consumePlan({ workspace, planPath, outputDirectory, run = execFileSync }) {
+ const plan = await readJson(planPath)
+ assert.ok(Array.isArray(plan.candidates), 'plan candidates must be an array')
+ assert.match(plan.web_revision, /^[a-f0-9]{40}$/, 'fixed Web source revision is required')
+ assert.equal(typeof plan.build_id, 'string', 'build ID is required')
+ assert.equal(
+ run('git', ['rev-parse', 'HEAD'], { cwd: workspace, encoding: 'utf8' }).trim(),
+ plan.web_revision,
+ 'Web checkout differs from the fixed release plan'
+ )
+ const producers = new Map()
+ for (const entry of await readdir(path.join(workspace, 'extensions'), { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue
+ const packagePath = path.join(workspace, 'extensions', entry.name, 'package.json')
+ const manifest = await readJson(packagePath)
+ assert.ok(manifest.inkcre?.module_federation, `${entry.name}: native MF metadata is required`)
+ assert.ok(!producers.has(manifest.inkcre.name), 'duplicate native producer identity')
+ producers.set(manifest.inkcre.name, { directory: entry.name, packagePath, manifest })
+ }
+ const selected = []
+ const directories = new Set()
+ for (const candidate of plan.candidates) {
+ assert.match(candidate.directory, /^[a-zA-Z0-9_-]+$/, 'unsafe candidate directory')
+ assert.ok(!directories.has(candidate.directory), 'duplicate candidate directory')
+ directories.add(candidate.directory)
+ assert.equal(validSemVer(candidate.version), candidate.version, 'invalid Release version')
+ assert.equal(candidate.prepare?.version, candidate.version, 'prepare Release version differs')
+ const producer = producers.get(candidate.name)
+ const directoryProducer = [...producers.values()].find(
+ (value) => value.directory === candidate.directory
+ )
+ assert.ok(
+ !directoryProducer || directoryProducer === producer,
+ `${candidate.name}: Web producer directory declares a different identity`
+ )
+ if (producer) selected.push({ candidate, producer })
+ }
+ // Generate all selected versions before any build can read another workspace package.
+ for (const { candidate, producer } of selected) {
+ producer.manifest.version = candidate.version
+ await writeJson(producer.packagePath, producer.manifest)
+ }
+ if (selected.length)
+ run('pnpm', ['--filter', '@inkcre/core', 'build'], { cwd: workspace, stdio: 'inherit' })
+ await mkdir(outputDirectory, { recursive: true })
+ for (const { candidate, producer } of selected) {
+ run('pnpm', ['--filter', `./extensions/${producer.directory}`, 'build'], {
+ cwd: workspace,
+ stdio: 'inherit',
+ })
+ const artifactDirectory = path.join(path.dirname(producer.packagePath), 'dist/client-web')
+ const local = await inspectNativeModuleFederation({
+ packagePath: producer.packagePath,
+ corePackagePath: path.join(workspace, 'packages/core/package.json'),
+ artifactDirectory,
+ })
+ assert.equal(local.name, candidate.name)
+ assert.equal(local.version, candidate.version)
+ await listSnapshotFiles(artifactDirectory)
+ const destination = path.join(outputDirectory, candidate.directory)
+ await mkdir(destination, { recursive: true })
+ const archive = path.join(destination, 'snapshot.zip')
+ // Never replace saved bytes on a rerun; publication restores the whole Core-owned candidate.
+ await writeFile(path.join(destination, 'candidate.lock'), '', { flag: 'wx' })
+ run('zip', ['-q', '-r', archive, '.'], { cwd: artifactDirectory })
+ const prepare = prepareBody(local, {
+ source_repository: 'https://github.com/InKCre/client-web',
+ source_revision: plan.web_revision,
+ build_id: plan.build_id,
+ })
+ candidate.prepare.module_federation = prepare.module_federation
+ candidate.module_federation = {
+ file: `${candidate.directory}/snapshot.zip`,
+ sha256: createHash('sha256')
+ .update(await readFile(archive))
+ .digest('hex'),
+ }
+ }
+ await writeJson(path.join(outputDirectory, 'plan.json'), plan)
+ return plan
+}
+
+async function main() {
+ assert.equal(process.argv[2], 'consume-plan', 'expected consume-plan')
+ const options = new Map()
+ for (let i = 3; i < process.argv.length; i += 2) {
+ assert.ok(
+ process.argv[i]?.startsWith('--') && process.argv[i + 1],
+ 'expected option/value pair'
+ )
+ options.set(process.argv[i], process.argv[i + 1])
+ }
+ assert.ok(options.get('--plan') && options.get('--output'), '--plan and --output are required')
+ await consumePlan({
+ workspace: process.cwd(),
+ planPath: path.resolve(options.get('--plan')),
+ outputDirectory: path.resolve(options.get('--output')),
+ })
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ main().catch((error) => {
+ console.error(`[ERROR] ${error.message}`)
+ process.exitCode = 1
+ })
+}
diff --git a/scripts/publish-first-party-extensions.mjs b/scripts/publish-first-party-extensions.mjs
deleted file mode 100644
index 1ca0d481..00000000
--- a/scripts/publish-first-party-extensions.mjs
+++ /dev/null
@@ -1,350 +0,0 @@
-import assert from 'node:assert/strict'
-import { execFileSync } from 'node:child_process'
-import { createHash } from 'node:crypto'
-import { appendFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
-import os from 'node:os'
-import path from 'node:path'
-import { setTimeout } from 'node:timers/promises'
-import { fileURLToPath } from 'node:url'
-import {
- inspectNativeModuleFederation,
- listSnapshotFiles,
- prepareBody,
- verifyPublicModuleFederation,
-} from './verify-native-extension-distribution.mjs'
-
-const ARTIFACT_NAME = 'first-party-extension-candidate'
-const OBJECTS_PENDING = 'Release native Distribution objects are not completely available'
-const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
-const readJson = async (file) => JSON.parse(await readFile(file, 'utf8'))
-const writeJson = (file, data) => writeFile(file, `${JSON.stringify(data, null, 2)}\n`)
-
-export function selectSavedCandidate(artifacts, runAttempt) {
- assert.match(runAttempt, /^[1-9]\d*$/, 'current GitHub run attempt is required')
- const candidates = artifacts.filter((artifact) => artifact.name === ARTIFACT_NAME)
- assert.ok(candidates.length <= 1, 'workflow run has multiple release candidates')
- const candidate = candidates[0]
- assert.ok(!candidate?.expired, 'saved candidate expired; do not rebuild a prepared Release')
- // GitHub deletes artifacts on rerun-all. Only partial reruns can retain the original candidate.
- // ponytail: also reject pre-capture retries; operator review is safer than inferring prior writes.
- assert.ok(
- candidate || runAttempt === '1',
- 'Saved candidate is missing on a rerun. Stop: never rebuild a possibly prepared Release. Use the original artifact and rerun only publication; see first-party delivery recovery guidance.'
- )
- return candidate
-}
-
-class RegistryError extends Error {
- constructor(stage, status, detail) {
- super(`${stage}: Registry HTTP ${status || 'unavailable'}: ${detail}`)
- this.status = status
- this.detail = detail
- this.uncertain = [0, 408, 500, 502, 503, 504].includes(status)
- }
-}
-
-async function request(url, options, fetchImplementation) {
- let response
- let body
- try {
- response = await fetchImplementation(url, {
- ...options,
- redirect: 'error',
- signal: AbortSignal.timeout(45_000),
- })
- body = await response.text()
- } catch (error) {
- throw new RegistryError(`${options.method ?? 'GET'} ${url.pathname}`, 0, error.message)
- }
- let result
- try {
- result = JSON.parse(body)
- } catch {
- result = { detail: body.slice(0, 500) }
- }
- if (!response.ok) {
- throw new RegistryError(
- `${options.method ?? 'GET'} ${url.pathname}`,
- response.status,
- result.detail ?? body.slice(0, 500)
- )
- }
- return result
-}
-
-function releaseUrl(registryUrl, name, version) {
- return new URL(`/v1/extensions/${name}/releases/${version}`, registryUrl)
-}
-
-async function publicRelease(url, fetchImplementation) {
- try {
- const release = await request(url, {}, fetchImplementation)
- assert.equal(release.state, 'published', `cannot deliver a ${release.state} Release`)
- return release
- } catch (error) {
- if (error.status === 404) return null
- throw error
- }
-}
-
-export async function captureCandidates({
- workspace,
- deliveryDirectory,
- registryUrl,
- provenance,
- fetchImplementation = fetch,
-}) {
- await mkdir(deliveryDirectory, { recursive: true })
- const candidates = []
- for (const directory of await readdir(path.join(workspace, 'extensions'), {
- withFileTypes: true,
- })) {
- if (!directory.isDirectory()) continue
- const packagePath = path.join(workspace, 'extensions', directory.name, 'package.json')
- let manifest
- try {
- manifest = await readJson(packagePath)
- } catch (error) {
- if (error.code === 'ENOENT') continue
- throw error
- }
- if (!manifest.inkcre?.module_federation) continue
- const existing = await publicRelease(
- releaseUrl(registryUrl, manifest.inkcre.name, manifest.version),
- fetchImplementation
- )
- if (existing?.module_federation) {
- console.log(
- `${manifest.inkcre.name} ${manifest.version}: historical Release, no publication selected.`
- )
- continue
- }
- const artifactDirectory = path.join(path.dirname(packagePath), 'dist/client-web')
- const local = await inspectNativeModuleFederation({
- packagePath,
- corePackagePath: path.join(workspace, 'packages/core/package.json'),
- artifactDirectory,
- })
- await listSnapshotFiles(artifactDirectory)
- const candidateDirectory = path.join(deliveryDirectory, directory.name)
- // A candidate is written once. Re-runs restore the saved artifact before entering this command.
- await mkdir(candidateDirectory)
- const prepare = prepareBody(local, provenance)
- await writeJson(path.join(candidateDirectory, 'prepare.json'), prepare)
- const archive = path.join(candidateDirectory, 'snapshot.zip')
- execFileSync('zip', ['-q', '-r', archive, '.'], { cwd: artifactDirectory })
- candidates.push({
- directory: directory.name,
- name: local.name,
- version: local.version,
- sha256: sha256(await readFile(archive)),
- })
- }
- const plan = { ...provenance, registry_url: registryUrl, candidates }
- await writeJson(path.join(deliveryDirectory, 'plan.json'), plan)
- return plan
-}
-
-export async function checkCandidates({ workspace, deliveryDirectory, registryUrl, provenance }) {
- const plan = await readJson(path.join(deliveryDirectory, 'plan.json'))
- assert.deepEqual(
- {
- source_repository: plan.source_repository,
- source_revision: plan.source_revision,
- build_id: plan.build_id,
- registry_url: plan.registry_url,
- },
- { ...provenance, registry_url: registryUrl },
- 'saved candidate must belong to this source revision, workflow run and Registry'
- )
- for (const candidate of plan.candidates) {
- assert.match(candidate.directory, /^[a-zA-Z0-9_-]+$/)
- const candidateDirectory = path.join(deliveryDirectory, candidate.directory)
- const archive = await readFile(path.join(candidateDirectory, 'snapshot.zip'))
- assert.equal(sha256(archive), candidate.sha256, `${candidate.name}: saved ZIP digest differs`)
- const artifactDirectory = await mkdtemp(path.join(os.tmpdir(), 'inkcre-release-candidate-'))
- try {
- execFileSync('unzip', [
- '-q',
- path.join(candidateDirectory, 'snapshot.zip'),
- '-d',
- artifactDirectory,
- ])
- const local = await inspectNativeModuleFederation({
- packagePath: path.join(workspace, 'extensions', candidate.directory, 'package.json'),
- corePackagePath: path.join(workspace, 'packages/core/package.json'),
- artifactDirectory,
- })
- await listSnapshotFiles(artifactDirectory)
- assert.equal(candidate.name, local.name)
- assert.equal(candidate.version, local.version)
- assert.deepEqual(
- await readJson(path.join(candidateDirectory, 'prepare.json')),
- prepareBody(local, provenance)
- )
- } finally {
- await rm(artifactDirectory, { recursive: true })
- }
- }
- return plan
-}
-
-async function finishPublication(url, token, fetchImplementation) {
- // A new MF-only Release stays invisible while preparing. Retry publish, not just public GET.
- for (let attempt = 0; attempt < 7; attempt++) {
- if (attempt) await setTimeout(5_000)
- try {
- const result = await request(
- new URL(`${url.pathname}/publish`, url),
- {
- method: 'POST',
- headers: { Authorization: `Bearer ${token}` },
- },
- fetchImplementation
- )
- if (result.state === 'published' && result.module_federation) return
- } catch (error) {
- if (!error.uncertain && !(error.status === 409 && error.detail === OBJECTS_PENDING))
- throw error
- console.warn(error.message)
- }
- try {
- const visible = await publicRelease(url, fetchImplementation)
- if (visible?.module_federation) return
- } catch (error) {
- if (!error.uncertain) throw error
- console.warn(error.message)
- }
- }
- throw new Error(
- 'Publication is not confirmed. Re-run the failed job with its saved candidate; do not rebuild or replace the Release.'
- )
-}
-
-export async function publishCandidates(options) {
- const { workspace, deliveryDirectory, registryUrl, token, fetchImplementation = fetch } = options
- assert.ok(token, 'publisher token is required')
- const plan = await checkCandidates(options)
- for (const candidate of plan.candidates) {
- const candidateDirectory = path.join(deliveryDirectory, candidate.directory)
- const url = releaseUrl(registryUrl, candidate.name, candidate.version)
- const prepare = await readFile(path.join(candidateDirectory, 'prepare.json'))
- // prepare is idempotent and also checks immutable provenance before a resumed publication.
- await request(
- new URL(`/v1/extensions/${candidate.name}/releases`, registryUrl),
- {
- method: 'POST',
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
- body: prepare,
- },
- fetchImplementation
- )
- const existing = await publicRelease(url, fetchImplementation)
- if (!existing?.module_federation) {
- const form = new FormData()
- form.append(
- 'content',
- new Blob([await readFile(path.join(candidateDirectory, 'snapshot.zip'))], {
- type: 'application/zip',
- }),
- 'snapshot.zip'
- )
- try {
- await request(
- new URL(`${url.pathname}/module-federation`, url),
- {
- method: 'POST',
- headers: { Authorization: `Bearer ${token}` },
- body: form,
- },
- fetchImplementation
- )
- } catch (error) {
- if (!error.uncertain) throw error
- console.warn(`${error.message}; upload outcome is unknown, checking completion.`)
- }
- await finishPublication(url, token, fetchImplementation)
- }
- const artifactDirectory = await mkdtemp(path.join(os.tmpdir(), 'inkcre-release-verification-'))
- try {
- execFileSync('unzip', [
- '-q',
- path.join(candidateDirectory, 'snapshot.zip'),
- '-d',
- artifactDirectory,
- ])
- const result = await verifyPublicModuleFederation({
- registryUrl,
- packagePath: path.join(workspace, 'extensions', candidate.directory, 'package.json'),
- corePackagePath: path.join(workspace, 'packages/core/package.json'),
- artifactDirectory,
- fetchImplementation,
- })
- const summary = `${candidate.name} ${candidate.version}: published and verified (${result.verified_assets} files), ZIP SHA256 ${candidate.sha256}.\n`
- console.log(summary.trim())
- if (process.env.GITHUB_STEP_SUMMARY)
- await appendFile(process.env.GITHUB_STEP_SUMMARY, summary)
- } finally {
- await rm(artifactDirectory, { recursive: true })
- }
- }
-}
-
-async function main() {
- const command = process.argv[2]
- if (command === 'locate') {
- const {
- GITHUB_REPOSITORY: repository,
- GITHUB_RUN_ID: runId,
- GITHUB_RUN_ATTEMPT: runAttempt,
- GITHUB_OUTPUT: output,
- } = process.env
- assert.ok(repository && runId && output, 'locate requires the current GitHub workflow run')
- const pages = JSON.parse(
- execFileSync(
- 'gh',
- ['api', `repos/${repository}/actions/runs/${runId}/artifacts`, '--paginate', '--slurp'],
- { encoding: 'utf8' }
- )
- )
- const candidate = selectSavedCandidate(
- pages.flatMap((page) => page.artifacts),
- runAttempt
- )
- await appendFile(output, `artifact_id=${candidate?.id ?? ''}\n`)
- return
- }
- const {
- SOURCE_REPOSITORY: repository,
- SOURCE_REVISION: revision,
- GITHUB_RUN_ID: runId,
- INKCRE_EXTENSION_REGISTRY_URL: registryUrl,
- } = process.env
- assert.ok(
- repository && revision && runId && registryUrl,
- 'source, workflow run and Registry are required'
- )
- assert.equal(new URL(registryUrl).protocol, 'https:', 'Registry must use HTTPS')
- const options = {
- workspace: process.cwd(),
- deliveryDirectory: path.resolve('.extension-delivery'),
- registryUrl,
- provenance: {
- source_repository: repository,
- source_revision: revision,
- build_id: `client-web-extension-release-${runId}`,
- },
- }
- if (command === 'capture') await captureCandidates(options)
- else if (command === 'check') await checkCandidates(options)
- else if (command === 'publish')
- await publishCandidates({ ...options, token: process.env.INKCRE_EXTENSION_REGISTRY_TOKEN })
- else throw new Error(`unsupported command: ${command}`)
-}
-
-if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
- main().catch((error) => {
- console.error(`[ERROR] ${error.message}`)
- process.exitCode = 1
- })
-}
diff --git a/scripts/test-extension-publication.mjs b/scripts/test-extension-publication.mjs
deleted file mode 100644
index f7234eff..00000000
--- a/scripts/test-extension-publication.mjs
+++ /dev/null
@@ -1,209 +0,0 @@
-import assert from 'node:assert/strict'
-import { execFileSync } from 'node:child_process'
-import { once } from 'node:events'
-import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
-import { createServer } from 'node:http'
-import os from 'node:os'
-import path from 'node:path'
-import { test } from 'node:test'
-import {
- captureCandidates,
- checkCandidates,
- publishCandidates,
- selectSavedCandidate,
-} from './publish-first-party-extensions.mjs'
-
-test('a rerun cannot replace a missing or expired release candidate', () => {
- const candidate = { id: 42, name: 'first-party-extension-candidate', expired: false }
- const unrelated = { id: 43, name: 'other-artifact', expired: false }
- assert.equal(selectSavedCandidate([unrelated], '1'), undefined)
- assert.equal(selectSavedCandidate([unrelated, candidate], '2'), candidate)
- assert.throws(() => selectSavedCandidate([unrelated], '2'), /missing on a rerun/)
- assert.throws(() => selectSavedCandidate([{ ...candidate, expired: true }], '2'), /expired/)
- assert.throws(() => selectSavedCandidate([candidate, candidate], '2'), /multiple/)
- assert.throws(() => selectSavedCandidate([], undefined), /current GitHub run attempt/)
-})
-
-// A real HTTP fault-injection boundary for the publisher, not a replacement Registry implementation.
-test('an unknown MF-only upload resumes the saved ZIP and verifies even an existing association', async () => {
- const workspace = await mkdtemp(path.join(os.tmpdir(), 'inkcre-publication-test-'))
- const registryUrl = 'https://registry.example.test'
- const prefix = '/extensions/test/source/1.0.0/module-federation/'
- const build = path.join(workspace, 'extensions/source/dist/client-web')
- const uploaded = path.join(workspace, 'uploaded')
- let prepared
- let published = false
- let uploads = 0
- let publishes = 0
- let writes = 0
- let corrupt = false
- let forceConflict = false
- const server = createServer(async (req, res) => {
- try {
- res.setHeader('Access-Control-Allow-Origin', '*')
- const json = (status, body) => {
- res.writeHead(status, { 'Content-Type': 'application/json' })
- res.end(JSON.stringify(body))
- }
- const record = () => ({
- name: 'test/source',
- nickname: 'Source',
- version: '1.0.0',
- state: 'published',
- module_federation: {
- host_sdk: '@inkcre/core',
- host_sdk_version: '>=0.2.0 <0.4.0',
- manifest_url: `${prefix}mf-manifest.json`,
- },
- })
- if (req.method === 'POST') {
- writes++
- assert.equal(req.headers.authorization, 'Bearer test-only')
- const chunks = []
- for await (const chunk of req) chunks.push(chunk)
- const body = Buffer.concat(chunks)
- if (req.url.endsWith('/releases')) {
- if (forceConflict)
- return json(409, { detail: 'Module Federation association is immutable' })
- const value = JSON.parse(body)
- if (prepared) assert.deepEqual(value, prepared)
- prepared = value
- return json(200, { state: 'preparing' })
- }
- if (req.url.endsWith('/module-federation')) {
- uploads++
- const form = await new Response(body, {
- headers: { 'Content-Type': req.headers['content-type'] },
- }).formData()
- const zip = path.join(workspace, 'received.zip')
- await writeFile(zip, Buffer.from(await form.get('content').arrayBuffer()))
- execFileSync('unzip', ['-q', zip, '-d', uploaded])
- // Heroku can return H12 while the server keeps processing this exact archive.
- return json(503, { detail: 'H12 request timeout' })
- }
- if (req.url.endsWith('/publish')) {
- publishes++
- if (publishes === 1)
- return json(409, {
- detail: 'Release native Distribution objects are not completely available',
- })
- published = true
- return json(200, record())
- }
- }
- if (req.url.startsWith('/v1/'))
- return json(published ? 200 : 404, published ? record() : { detail: 'not found' })
- if (!published) return json(404, { detail: 'not found' })
- const relativePath = req.url.slice(prefix.length)
- let bytes = await readFile(path.join(uploaded, relativePath))
- if (relativePath === 'mf-manifest.json') {
- const manifest = JSON.parse(bytes)
- manifest.metaData.publicPath = `${registryUrl}${prefix}`
- bytes = Buffer.from(JSON.stringify(manifest))
- }
- if (corrupt && relativePath === 'transitive.js') bytes = Buffer.from('changed')
- res.writeHead(200, { 'Cache-Control': 'public, no-cache', ETag: '"snapshot"' })
- res.end(bytes)
- } catch (error) {
- res.writeHead(500)
- res.end(error.message)
- }
- })
- try {
- await mkdir(path.join(build, '.vite'), { recursive: true })
- await mkdir(path.join(workspace, 'extensions/.agents'))
- await mkdir(path.join(workspace, 'extensions/guidance'))
- await mkdir(path.join(workspace, 'packages/core'), { recursive: true })
- await writeFile(
- path.join(workspace, 'packages/core/package.json'),
- JSON.stringify({ version: '0.3.0' })
- )
- await writeFile(
- path.join(workspace, 'extensions/source/package.json'),
- JSON.stringify({
- version: '1.0.0',
- inkcre: {
- name: 'test/source',
- nickname: 'Source',
- module_federation: { host_sdk: '@inkcre/core', host_sdk_version: '>=0.2.0 <0.4.0' },
- },
- })
- )
- await writeFile(
- path.join(build, 'mf-manifest.json'),
- JSON.stringify({
- name: 'source',
- metaData: { publicPath: './', remoteEntry: { name: 'remoteEntry.js' } },
- shared: [{ name: '@inkcre/core', requiredVersion: '>=0.2.0 <0.4.0' }],
- exposes: [],
- })
- )
- await writeFile(path.join(build, 'remoteEntry.js'), 'import "./transitive.js"')
- await writeFile(path.join(build, 'transitive.js'), 'export default 42')
- await writeFile(path.join(build, '.vite/manifest.json'), '{}')
- server.listen(0, '127.0.0.1')
- await once(server, 'listening')
- const origin = `http://127.0.0.1:${server.address().port}`
- const options = {
- workspace,
- registryUrl,
- deliveryDirectory: path.join(workspace, 'candidate'),
- token: 'test-only',
- provenance: {
- source_repository: 'https://github.com/InKCre/client-web',
- source_revision: 'exact-source',
- build_id: 'same-run',
- },
- fetchImplementation: (url, init) => fetch(new URL(new URL(url).pathname, origin), init),
- }
- await captureCandidates(options)
- assert.equal(writes, 0, 'capturing a candidate must not mutate the Registry')
- // A rebuild could produce other bytes; publication must still use the retained ZIP.
- await writeFile(path.join(build, 'transitive.js'), 'export default "different build"')
- await publishCandidates(options)
- assert.equal(uploads, 1)
- assert.equal(
- publishes,
- 2,
- 'MF-only preparation must progress through publish, not public GET alone'
- )
- await publishCandidates(options)
- assert.equal(uploads, 1, 'a ready candidate is verified without uploading it again')
- corrupt = true
- await assert.rejects(publishCandidates(options), /public asset bytes differ: transitive.js/)
- corrupt = false
- forceConflict = true
- const beforeConflict = writes
- await assert.rejects(publishCandidates(options), /association is immutable/)
- assert.equal(writes, beforeConflict + 1, 'immutable conflicts must not retry')
- forceConflict = false
- const beforeInvalid = writes
- await assert.rejects(
- checkCandidates({ ...options, provenance: { ...options.provenance, build_id: 'new-run' } }),
- /saved candidate must belong/
- )
- await writeFile(path.join(options.deliveryDirectory, 'source/snapshot.zip'), 'corrupt')
- await assert.rejects(publishCandidates(options), /saved ZIP digest differs/)
- assert.equal(writes, beforeInvalid, 'invalid saved candidates must fail before Registry writes')
- await rm(build, { recursive: true })
- const historical = await captureCandidates({
- ...options,
- deliveryDirectory: path.join(workspace, 'later-run'),
- })
- assert.deepEqual(
- historical.candidates,
- [],
- 'unrelated builds must not claim to verify historical releases'
- )
- await writeFile(path.join(workspace, 'extensions/guidance/package.json'), '{invalid')
- await assert.rejects(
- captureCandidates({ ...options, deliveryDirectory: path.join(workspace, 'invalid-run') }),
- SyntaxError,
- 'only absent package metadata may be skipped, not invalid package metadata'
- )
- } finally {
- server.closeAllConnections()
- await new Promise((resolve) => server.close(resolve))
- await rm(workspace, { recursive: true })
- }
-})
diff --git a/scripts/test-native-extension-candidates.mjs b/scripts/test-native-extension-candidates.mjs
new file mode 100644
index 00000000..a8aaf182
--- /dev/null
+++ b/scripts/test-native-extension-candidates.mjs
@@ -0,0 +1,72 @@
+import assert from 'node:assert/strict'
+import { execFileSync } from 'node:child_process'
+import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { test } from 'node:test'
+import { consumePlan } from './build-native-extension-candidates.mjs'
+
+const writeJson = (file, data) => writeFile(file, JSON.stringify(data))
+
+test('one plan generates MF version and provenance while preserving Python-only candidates', async () => {
+ const workspace = await mkdtemp(path.join(os.tmpdir(), 'inkcre-assembly-'))
+ try {
+ await mkdir(path.join(workspace, 'extensions/mail/dist/client-web'), { recursive: true })
+ await mkdir(path.join(workspace, 'packages/core'), { recursive: true })
+ await writeJson(path.join(workspace, 'packages/core/package.json'), { version: '0.3.0' })
+ const packagePath = path.join(workspace, 'extensions/mail/package.json')
+ await writeJson(packagePath, {
+ version: '0.1.0',
+ inkcre: {
+ name: 'inkcre/mail',
+ nickname: 'Mail',
+ module_federation: { host_sdk: '@inkcre/core', host_sdk_version: '>=0.2.0 <0.4.0' },
+ },
+ })
+ const dist = path.join(workspace, 'extensions/mail/dist/client-web')
+ await writeFile(path.join(dist, 'remoteEntry.js'), 'export default {}')
+ await writeJson(path.join(dist, 'mf-manifest.json'), {
+ metaData: { publicPath: './', remoteEntry: { name: 'remoteEntry.js' } },
+ shared: [{ name: '@inkcre/core', requiredVersion: '>=0.2.0 <0.4.0' }],
+ exposes: [],
+ })
+ const plan = {
+ web_revision: 'a'.repeat(40),
+ build_id: 'core-release-123',
+ candidates: ['mail', 'github'].map((directory) => ({
+ directory,
+ name: `inkcre/${directory}`,
+ version: '0.3.99',
+ prepare: { version: '0.3.99', python: { package: directory } },
+ })),
+ }
+ const planPath = path.join(workspace, 'input.json')
+ await writeJson(planPath, plan)
+ const options = {
+ workspace,
+ planPath,
+ outputDirectory: path.join(workspace, 'output'),
+ run(command, args, settings) {
+ if (command === 'git') return plan.web_revision
+ if (command === 'zip') execFileSync(command, args, settings)
+ },
+ }
+ const result = await consumePlan(options)
+ assert.equal(JSON.parse(await readFile(packagePath)).version, '0.3.99')
+ assert.deepEqual(result.candidates[1], plan.candidates[1])
+ assert.equal(result.candidates[0].prepare.python.package, 'mail')
+ assert.equal(result.candidates[0].prepare.module_federation.source_revision, plan.web_revision)
+ assert.match(result.candidates[0].module_federation.sha256, /^[a-f0-9]{64}$/)
+ assert.equal(result.candidates[0].module_federation.file, 'mail/snapshot.zip')
+ await assert.rejects(consumePlan(options), { code: 'EEXIST' })
+ plan.candidates[0].name = 'inkcre/typo'
+ await writeJson(planPath, plan)
+ await assert.rejects(consumePlan(options), /different identity/)
+ plan.candidates[0].name = 'inkcre/mail'
+ plan.candidates[0].directory = '../escape'
+ await writeJson(planPath, plan)
+ await assert.rejects(consumePlan(options), /unsafe candidate directory/)
+ } finally {
+ await rm(workspace, { recursive: true, force: true })
+ }
+})
From 2f903c85b9ec910172425027a68a9efac407f5eb Mon Sep 17 00:00:00 2001
From: Lan_zhijiang
Date: Tue, 13 Oct 2026 19:13:01 +0800
Subject: [PATCH 03/27] =?UTF-8?q?docs(shared):=20=E6=9B=B4=E6=96=B0?=
=?UTF-8?q?=E7=8B=AC=E7=AB=8B=E5=88=86=E5=8F=91=E4=B8=8E=20Peer=20?=
=?UTF-8?q?=E5=8F=82=E4=B8=8E=E5=A5=91=E7=BA=A6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/_shared | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/_shared b/docs/_shared
index 2031cf0f..11b465f4 160000
--- a/docs/_shared
+++ b/docs/_shared
@@ -1 +1 @@
-Subproject commit 2031cf0f453ed694d9736477258bd798af64ac54
+Subproject commit 11b465f4d0be95075a99955181a20c797374019e
From 55b85a439086c5dc5d6a85c2e5228555470d691e Mon Sep 17 00:00:00 2001
From: Lan_zhijiang
Date: Tue, 13 Oct 2026 19:33:38 +0800
Subject: [PATCH 04/27] =?UTF-8?q?feat(core):=20=E5=B0=86=E5=8E=9F=E7=94=9F?=
=?UTF-8?q?=E6=8F=92=E4=BB=B6=E7=89=88=E6=9C=AC=E4=B8=8E=E5=90=AF=E7=94=A8?=
=?UTF-8?q?=E6=84=8F=E5=9B=BE=E4=B8=8B=E7=A7=BB=E5=88=B0=20Peer?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/core/CHANGELOG.md | 13 +
packages/core/package.json | 2 +-
.../core/src/database/database.generated.ts | 69 ++--
.../database/runtime-contract.generated.json | 2 +-
packages/core/src/extension/index.ts | 7 +
packages/core/src/extension/model.ts | 310 ++++++++++++------
packages/core/src/index.ts | 7 +
7 files changed, 276 insertions(+), 134 deletions(-)
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index 0f10a4e8..d8f22c20 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,18 @@
# @inkcre/core
+## 0.4.0
+
+### Minor Changes
+
+- 插件配置与业务状态在部署内共享;精确分发版本和启用意图分别保存在每个 Peer。Client Web 在同一插件入口管理各 Peer 的安装、版本和运行状态,并采用 Extension management v2。
+
+ 原生 SDK 不再提供全局插件版本与 enabled 数组。新版 MF 使用 0.4 SDK 的参与记录接口,历史 MF 需按其 SDK 范围运行或明确选择新版;不会自动改写旧版本选择。Python 与 MF 分别从自己的仓库发布,插件负责共享数据契约兼容。
+
+### Patch Changes
+
+- 7307dda: 按任务精简 Settings、Peer 与 Extension 界面,统一页面和浏览器标题,补齐场景化加载、刷新失败保留、保存与取消的动作反馈。修正重复检索、任务不存在及日志读取失败的可观察结果,保留失败后的恢复入口。
+- 7307dda: Source 与 Peer 配置使用可视化表单,保留完整 JSON 编辑模式;Peers 页面可确认删除离线 Peer 并清理其扩展启用记录。Extension 浏览与版本选择交由 Registry,Web 接收精确版本并确认安装;Web 与 Module Federation 扩展统一升级正式 UI 2.2.0,Peer AI 凭据在 schema 中标记为密码输入。
+
## 0.3.0
### Minor Changes
diff --git a/packages/core/package.json b/packages/core/package.json
index c2de786f..fa1df6e9 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@inkcre/core",
- "version": "0.3.0",
+ "version": "0.4.0",
"files": [
"dist"
],
diff --git a/packages/core/src/database/database.generated.ts b/packages/core/src/database/database.generated.ts
index b8901e9d..1273484e 100644
--- a/packages/core/src/database/database.generated.ts
+++ b/packages/core/src/database/database.generated.ts
@@ -366,33 +366,66 @@ export type Database = {
},
]
}
+ extension_peer_participations: {
+ Row: {
+ config_schema: Json | null
+ distribution: string | null
+ enabled: boolean
+ name: string
+ peer_id: string
+ version: string
+ }
+ Insert: {
+ config_schema?: Json | null
+ distribution?: string | null
+ enabled?: boolean
+ name: string
+ peer_id: string
+ version: string
+ }
+ Update: {
+ config_schema?: Json | null
+ distribution?: string | null
+ enabled?: boolean
+ name?: string
+ peer_id?: string
+ version?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: 'extension_peer_participations_name_fkey'
+ columns: ['name']
+ isOneToOne: false
+ referencedRelation: 'extensions'
+ referencedColumns: ['name']
+ },
+ {
+ foreignKeyName: 'extension_peer_participations_peer_id_fkey'
+ columns: ['peer_id']
+ isOneToOne: false
+ referencedRelation: 'peers'
+ referencedColumns: ['id']
+ },
+ ]
+ }
extensions: {
Row: {
config: Json
- config_schema: Json | null
- enabled: string[]
name: string
nickname: string | null
state: Json
- version: string
}
Insert: {
config?: Json
- config_schema?: Json | null
- enabled?: string[]
name: string
nickname?: string | null
state?: Json
- version: string
}
Update: {
config?: Json
- config_schema?: Json | null
- enabled?: string[]
name?: string
nickname?: string | null
state?: Json
- version?: string
}
Relationships: []
}
@@ -827,24 +860,6 @@ export type Database = {
Args: { peer: string; ttl_seconds: number }
Returns: string
}
- set_extension_peer_enabled: {
- Args: { p_enabled: boolean; p_name: string; p_peer_id: string }
- Returns: {
- config: Json
- config_schema: Json | null
- enabled: string[]
- name: string
- nickname: string | null
- state: Json
- version: string
- }[]
- SetofOptions: {
- from: '*'
- to: 'extensions'
- isOneToOne: false
- isSetofReturn: true
- }
- }
}
Enums: {
jobstatus: 'pending' | 'running' | 'finished' | 'failed' | 'timed_out' | 'aborted'
diff --git a/packages/core/src/database/runtime-contract.generated.json b/packages/core/src/database/runtime-contract.generated.json
index 320ae1ef..98a2645f 100644
--- a/packages/core/src/database/runtime-contract.generated.json
+++ b/packages/core/src/database/runtime-contract.generated.json
@@ -1,6 +1,6 @@
{
"format": 1,
- "revision": "peer-runtime-identity-v1",
+ "revision": "extension-peer-participation-v2",
"protocol": {
"format": 1,
"schema": "inkcre"
diff --git a/packages/core/src/extension/index.ts b/packages/core/src/extension/index.ts
index 91de33c3..a2980716 100644
--- a/packages/core/src/extension/index.ts
+++ b/packages/core/src/extension/index.ts
@@ -1,4 +1,11 @@
export {
+ NativeDistributionSchema,
+ ExtensionParticipationSchema,
+ ExtensionParticipationModel,
+ ExtensionRuntimeStatusSchema,
+ type NativeDistribution,
+ type ExtensionParticipation,
+ type ExtensionRuntimeStatus,
ExtensionNameSchema,
ExtensionVersionSchema,
InstalledExtensionSchema,
diff --git a/packages/core/src/extension/model.ts b/packages/core/src/extension/model.ts
index cc9f4380..7e11b4c6 100644
--- a/packages/core/src/extension/model.ts
+++ b/packages/core/src/extension/model.ts
@@ -20,29 +20,50 @@ export const ExtensionVersionSchema = z.string().refine(
{ error: 'Extension version must be strict SemVer without build metadata.' }
)
-/** One deployment-wide installed Extension row. */
+export const NativeDistributionSchema = z.enum(['python', 'module_federation'])
+export type NativeDistribution = z.infer
+
+/** Shared identity and configuration; native versions belong to individual Peers. */
export const InstalledExtensionSchema = z.object({
name: ExtensionNameSchema,
- version: ExtensionVersionSchema,
- enabled: z.array(z.uuid()).default([]),
nickname: z.string().nullable(),
config: z.record(z.string(), z.unknown()).default({}),
- config_schema: z.record(z.string(), z.unknown()).nullable(),
})
-
-export const InstallExtensionInputSchema = InstalledExtensionSchema.pick({
- name: true,
- version: true,
+export const InstallExtensionInputSchema = z.object({
+ name: ExtensionNameSchema,
+ version: ExtensionVersionSchema,
})
-
export type InstalledExtension = z.infer
export type InstallExtensionInput = z.infer
-export interface ExtensionModelInstallInput extends InstallExtensionInput {
+export interface ExtensionModelInstallInput {
+ name: string
nickname: string
}
-const EXTENSION_MANAGEMENT_PROJECTION =
- 'name,version,enabled,nickname,config,config_schema' as const
+export const ExtensionParticipationSchema = z.object({
+ name: ExtensionNameSchema,
+ peer_id: z.uuid(),
+ distribution: NativeDistributionSchema.nullable(),
+ version: ExtensionVersionSchema,
+ enabled: z.boolean(),
+ config_schema: z.record(z.string(), z.unknown()).nullable(),
+})
+export type ExtensionParticipation = z.infer
+export const ExtensionRuntimeStatusSchema = ExtensionParticipationSchema.omit({
+ version: true,
+}).extend({
+ version: ExtensionVersionSchema.nullable(),
+ supported_distributions: z.array(NativeDistributionSchema),
+ loaded_version: ExtensionVersionSchema.nullable(),
+ running: z.boolean(),
+ restart_required: z.boolean(),
+ operation: z.string().nullable(),
+ error: z.string().nullable(),
+})
+export type ExtensionRuntimeStatus = z.infer
+
+const EXTENSION_PROJECTION = 'name,nickname,config' as const
+const PARTICIPATION_PROJECTION = 'name,peer_id,distribution,version,enabled,config_schema' as const
export class ExtensionPersistenceError extends Error {
constructor(operation: string, cause?: unknown) {
@@ -53,32 +74,21 @@ export class ExtensionPersistenceError extends Error {
}
}
-/** Rich deployment Extension aggregate backed by the canonical PostgREST relation. */
export class ExtensionModel implements InstalledExtension {
static readonly #dbApi = new DBAPIClient<'extensions'>('extensions')
-
readonly name: string
- readonly version: string
- readonly enabled: string[]
readonly nickname: string | null
readonly config: Record
- readonly config_schema: Record | null
constructor(row: InstalledExtension) {
const parsed = InstalledExtensionSchema.parse(row)
this.name = parsed.name
- this.version = parsed.version
- this.enabled = parsed.enabled
this.nickname = parsed.nickname
this.config = parsed.config
- this.config_schema = parsed.config_schema
}
static async list(): Promise {
- const response = await this.#dbApi
- .from()
- .select(EXTENSION_MANAGEMENT_PROJECTION)
- .order('name', { ascending: true })
+ const response = await this.#dbApi.from().select(EXTENSION_PROJECTION).order('name')
assertSuccess(response, 'list')
return InstalledExtensionSchema.array()
.parse(response.data ?? [])
@@ -88,7 +98,7 @@ export class ExtensionModel implements InstalledExtension {
static async get(name: string): Promise {
const response = await this.#dbApi
.from()
- .select(EXTENSION_MANAGEMENT_PROJECTION)
+ .select(EXTENSION_PROJECTION)
.eq('name', name)
.maybeSingle()
assertSuccess(response, 'read')
@@ -96,123 +106,213 @@ export class ExtensionModel implements InstalledExtension {
}
static async install(input: ExtensionModelInstallInput): Promise {
- const coordinate = InstallExtensionInputSchema.parse(input)
- const exact = InstalledExtensionSchema.parse({
- ...coordinate,
- nickname: input.nickname,
- enabled: [],
- config: {},
- config_schema: null,
- })
- const existing = await this.get(exact.name)
- if (existing) {
- if (existing.version === exact.version) return existing
- throw new ExtensionPersistenceError(
- 'install',
- new Error(
- `${exact.name} is already installed at ${existing.version}; disable every Peer before an explicit version change.`
- )
- )
- }
-
+ const name = ExtensionNameSchema.parse(input.name)
+ const existing = await this.get(name)
+ if (existing) return existing
+ // A competing Peer can create the shared identity; never overwrite its configuration.
const response = await this.#dbApi
- .insert({
- name: exact.name,
- version: exact.version,
- nickname: exact.nickname,
- config: exact.config,
- config_schema: exact.config_schema,
- })
- .select(EXTENSION_MANAGEMENT_PROJECTION)
- .single()
- assertSuccess(response, 'install')
- return this.fromRow(response.data)
- }
-
- async changeVersion(version: string, nickname: string): Promise {
- const exactVersion = ExtensionVersionSchema.parse(version)
- const response = await ExtensionModel.#dbApi
- .update({ version: exactVersion, nickname, config_schema: null })
- .eq('name', this.name)
- .filter('enabled', 'eq', '{}')
- .select(EXTENSION_MANAGEMENT_PROJECTION)
- .maybeSingle()
- assertSuccess(response, 'change version')
- if (response.data === null) {
- throw new ExtensionPersistenceError(
- 'change version',
- new Error(`Extension ${this.name} is missing or still enabled on a Peer.`)
+ .from()
+ .upsert(
+ { name, nickname: input.nickname, config: {}, state: {} },
+ { onConflict: 'name', ignoreDuplicates: true }
)
- }
- return ExtensionModel.fromRow(response.data)
+ assertSuccess(response, 'install')
+ const installed = await this.get(name)
+ if (!installed) throw new ExtensionPersistenceError('install')
+ return installed
}
async updateConfig(config: Record): Promise {
const response = await ExtensionModel.#dbApi
.update({ config })
.eq('name', this.name)
- .select(EXTENSION_MANAGEMENT_PROJECTION)
+ .select(EXTENSION_PROJECTION)
.single()
assertSuccess(response, 'update config')
return ExtensionModel.fromRow(response.data)
}
- enablePeer(peerId: string): Promise {
- return this.setPeerEnabled(peerId, true)
- }
-
- disablePeer(peerId: string): Promise {
- return this.setPeerEnabled(peerId, false)
- }
-
async uninstall(): Promise {
+ const participations = await ExtensionParticipationModel.list()
+ if (participations.some((row) => row.name === this.name)) {
+ throw new ExtensionPersistenceError(
+ 'uninstall',
+ new Error('Remove every Peer participation before deleting the shared Extension.')
+ )
+ }
const response = await ExtensionModel.#dbApi
.from()
.delete()
.eq('name', this.name)
- .filter('enabled', 'eq', '{}')
.select('name')
assertSuccess(response, 'uninstall')
- const deletedRows = InstalledExtensionSchema.pick({ name: true }).array().parse(response.data)
- if (deletedRows.length !== 1) {
+ if (response.data?.length !== 1) throw new ExtensionPersistenceError('uninstall')
+ }
+
+ private static fromRow(row: unknown): ExtensionModel {
+ return new ExtensionModel(InstalledExtensionSchema.parse(row))
+ }
+}
+
+/** One exact native selection. Runtime facts are obtained from the hosting Peer. */
+export class ExtensionParticipationModel implements ExtensionParticipation {
+ static readonly #dbApi = new DBAPIClient<'extension_peer_participations'>(
+ 'extension_peer_participations'
+ )
+ readonly name: string
+ readonly peer_id: string
+ readonly distribution: NativeDistribution | null
+ readonly version: string
+ readonly enabled: boolean
+ readonly config_schema: Record | null
+
+ constructor(row: ExtensionParticipation) {
+ const parsed = ExtensionParticipationSchema.parse(row)
+ this.name = parsed.name
+ this.peer_id = parsed.peer_id
+ this.distribution = parsed.distribution
+ this.version = parsed.version
+ this.enabled = parsed.enabled
+ this.config_schema = parsed.config_schema
+ }
+
+ static async list(peerId?: string): Promise {
+ let query = this.#dbApi.from().select(PARTICIPATION_PROJECTION).order('name')
+ if (peerId) query = query.eq('peer_id', peerId)
+ const response = await query
+ assertSuccess(response, 'list participation')
+ return ExtensionParticipationSchema.array()
+ .parse(response.data ?? [])
+ .map(this.fromRow)
+ }
+
+ static async get(peerId: string, name: string): Promise {
+ const response = await this.#dbApi
+ .from()
+ .select(PARTICIPATION_PROJECTION)
+ .eq('peer_id', peerId)
+ .eq('name', name)
+ .maybeSingle()
+ assertSuccess(response, 'read participation')
+ return response.data === null ? null : this.fromRow(response.data)
+ }
+
+ static async select(input: {
+ peerId: string
+ name: string
+ distribution: NativeDistribution
+ version: string
+ expectedVersion?: string
+ }): Promise {
+ const exact = ExtensionParticipationSchema.parse({
+ name: input.name,
+ peer_id: input.peerId,
+ distribution: input.distribution,
+ version: input.version,
+ enabled: false,
+ config_schema: null,
+ })
+ const existing = await this.get(exact.peer_id, exact.name)
+ if (!existing) {
+ if (input.expectedVersion !== undefined)
+ throw new ExtensionPersistenceError(
+ 'select version',
+ new Error('The previous selection no longer exists.')
+ )
+ const response = await this.#dbApi.insert(exact).select(PARTICIPATION_PROJECTION).single()
+ assertSuccess(response, 'select version')
+ return this.fromRow(response.data)
+ }
+ if (input.expectedVersion !== undefined && existing.version !== input.expectedVersion) {
throw new ExtensionPersistenceError(
- 'uninstall',
- new Error(`Extension ${this.name} is missing or still enabled on a Peer.`)
+ 'select version',
+ new Error('The Peer selection changed; refresh and retry.')
)
}
+ if (existing.distribution === exact.distribution && existing.version === exact.version)
+ return existing
+ const expected = input.expectedVersion ?? existing.version
+ let query = this.#dbApi
+ .update({ distribution: exact.distribution, version: exact.version, config_schema: null })
+ .eq('peer_id', exact.peer_id)
+ .eq('name', exact.name)
+ .eq('version', expected)
+ // Resolving a legacy pin must not replace a selection made by another Host.
+ query =
+ existing.distribution === null
+ ? query.is('distribution', null)
+ : query.eq('distribution', existing.distribution)
+ const response = await query.select(PARTICIPATION_PROJECTION).maybeSingle()
+ assertSuccess(response, 'select version')
+ if (!response.data)
+ throw new ExtensionPersistenceError(
+ 'select version',
+ new Error('The Peer selection changed; refresh and retry.')
+ )
+ return this.fromRow(response.data)
}
- private async setPeerEnabled(peerId: string, enabled: boolean): Promise {
- const response = await ExtensionModel.#dbApi
- .rpc('set_extension_peer_enabled', {
- p_name: this.name,
- p_peer_id: peerId,
- p_enabled: enabled,
- })
- .select(EXTENSION_MANAGEMENT_PROJECTION)
- assertSuccess(response, enabled ? 'enable Peer' : 'disable Peer')
- const updated = InstalledExtensionSchema.array().parse(response.data ?? [])[0]
- if (!updated) {
+ async setEnabled(enabled: boolean): Promise {
+ return this.updateExact({ enabled }, enabled ? 'enable Peer' : 'disable Peer')
+ }
+
+ async updateConfigSchema(
+ config_schema: Record | null
+ ): Promise {
+ return this.updateExact({ config_schema }, 'update config schema')
+ }
+
+ async remove(): Promise {
+ const response = await ExtensionParticipationModel.#dbApi
+ .from()
+ .delete()
+ .eq('peer_id', this.peer_id)
+ .eq('name', this.name)
+ .eq('enabled', false)
+ .eq('version', this.version)
+ .select('name')
+ assertSuccess(response, 'remove participation')
+ if (response.data?.length !== 1)
throw new ExtensionPersistenceError(
- enabled ? 'enable Peer' : 'disable Peer',
- new Error(`Extension ${this.name} does not exist.`)
+ 'remove participation',
+ new Error('Disable this Peer or refresh its selection first.')
)
- }
- return ExtensionModel.fromRow(updated)
}
- private static fromRow(row: unknown): ExtensionModel {
- return new ExtensionModel(InstalledExtensionSchema.parse(row))
+ private async updateExact(
+ values: Record,
+ operation: string
+ ): Promise {
+ let query = ExtensionParticipationModel.#dbApi
+ .update(values)
+ .eq('peer_id', this.peer_id)
+ .eq('name', this.name)
+ .eq('version', this.version)
+ query =
+ this.distribution === null
+ ? query.is('distribution', null)
+ : query.eq('distribution', this.distribution)
+ const response = await query.select(PARTICIPATION_PROJECTION).maybeSingle()
+ assertSuccess(response, operation)
+ if (!response.data)
+ throw new ExtensionPersistenceError(
+ operation,
+ new Error('The Peer selection changed; refresh and retry.')
+ )
+ return ExtensionParticipationModel.fromRow(response.data)
+ }
+
+ private static fromRow(row: unknown): ExtensionParticipationModel {
+ return new ExtensionParticipationModel(ExtensionParticipationSchema.parse(row))
}
}
function assertSuccess(response: { error?: { message: string } | null }, operation: string): void {
- if (response.error) {
+ if (response.error)
throw new ExtensionPersistenceError(
operation,
new APIError(response.error.message, 0, response.error)
)
- }
}
/** Setup UI contributed by one already-running Web Extension. */
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 530c9a2e..767c6154 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -21,6 +21,13 @@ export {
InstalledExtensionSchema,
InstallExtensionInputSchema,
ExtensionModel,
+ NativeDistributionSchema,
+ ExtensionParticipationSchema,
+ ExtensionParticipationModel,
+ ExtensionRuntimeStatusSchema,
+ type NativeDistribution,
+ type ExtensionParticipation,
+ type ExtensionRuntimeStatus,
ExtensionPersistenceError,
type ExtensionModule,
type ExtensionSetupContribution,
From 7f8e8250ec19160f6c304ff7346a6a76d43581ca Mon Sep 17 00:00:00 2001
From: Lan_zhijiang
Date: Tue, 13 Oct 2026 19:46:52 +0800
Subject: [PATCH 05/27] =?UTF-8?q?feat(extensions):=20=E7=8B=AC=E7=AB=8B?=
=?UTF-8?q?=E5=8F=91=E5=B8=83=20MF=20=E5=B9=B6=E6=8C=89=20Peer=20=E7=AE=A1?=
=?UTF-8?q?=E7=90=86=E5=8E=9F=E7=94=9F=E7=89=88=E6=9C=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.changeset/README.md | 7 +-
.changeset/config.json | 2 +-
.changeset/interaction-quality.md | 6 -
.changeset/schema-forms-tabs.md | 7 -
.github/workflows/package-release.yml | 57 --
apps/client-web/CHANGELOG.md | 15 +
apps/client-web/package.json | 4 +-
.../extension/extensionCard/extensionCard.md | 37 +-
.../extensionCard/extensionCard.scss | 10 +
.../extension/extensionCard/extensionCard.ts | 5 +-
.../extension/extensionCard/extensionCard.vue | 813 +++++++++---------
.../src/components/peer/peerCard/peerCard.vue | 13 +-
apps/client-web/src/core.ts | 1 +
apps/client-web/src/extension-peer-control.ts | 86 +-
apps/client-web/src/locales/messages/en.json | 26 +-
.../src/locales/messages/zh-CN.json | 26 +-
.../src/views/extensions/extensions.vue | 113 ++-
apps/client-webext/CHANGELOG.md | 9 +
apps/client-webext/package.json | 2 +-
docs/30-unit-tdd/native-extension-runtime.md | 71 +-
.../native-extension-delivery.md | 197 ++++-
extensions/AGENTS.md | 8 +-
extensions/mail/CHANGELOG.md | 14 +
extensions/mail/package.json | 4 +-
extensions/memos/CHANGELOG.md | 14 +
extensions/memos/package.json | 6 +-
extensions/memos/src/MemosSetup.vue | 35 +-
extensions/memos/src/setup.ts | 47 +-
extensions/twitter/CHANGELOG.md | 14 +
extensions/twitter/package.json | 7 +-
.../twitterSetupWizard/twitterSetupWizard.vue | 3 +-
extensions/twitter/src/setup-api.ts | 66 +-
packages/ext-dev-utils/CHANGELOG.md | 10 +
packages/ext-dev-utils/package.json | 2 +-
pnpm-lock.yaml | 30 +-
pnpm-workspace.yaml | 3 +-
scripts/build-native-extension-candidates.mjs | 122 ---
scripts/core-release-lib.mjs | 39 +
scripts/database-runtime-lib.mjs | 4 +-
scripts/publish-first-party-extensions.mjs | 361 ++++++++
scripts/resolve-core-release.mjs | 8 +-
scripts/sync-database-contract.mjs | 4 +-
scripts/test-core-release.mjs | 61 ++
scripts/test-extension-publication.mjs | 210 +++++
scripts/test-native-extension-candidates.mjs | 72 --
.../verify-native-extension-distribution.mjs | 15 +-
scripts/verify-pages-deployment.mjs | 2 +-
tests/e2e/peer-database.spec.ts | 230 ++---
48 files changed, 1907 insertions(+), 991 deletions(-)
delete mode 100644 .changeset/interaction-quality.md
delete mode 100644 .changeset/schema-forms-tabs.md
delete mode 100644 .github/workflows/package-release.yml
delete mode 100644 scripts/build-native-extension-candidates.mjs
create mode 100644 scripts/publish-first-party-extensions.mjs
create mode 100644 scripts/test-core-release.mjs
create mode 100644 scripts/test-extension-publication.mjs
delete mode 100644 scripts/test-native-extension-candidates.mjs
diff --git a/.changeset/README.md b/.changeset/README.md
index dee7d8d3..caf5b89c 100644
--- a/.changeset/README.md
+++ b/.changeset/README.md
@@ -1,6 +1,5 @@
# Changesets
-Application and SDK changes use `pnpm changeset`. The package Version PR consumes these files and
-updates their versions and changelogs; it does not publish Extensions. Native producers under
-`extensions/` are excluded: their shared Release version comes from core-py. Record native producer
-changes in the corresponding core-py Extension release fragment before selecting its Web revision.
+Each user-visible Extension change carries a small Markdown file that declares the affected
+Extension package and SemVer impact. Run `pnpm changeset` to create one. The automated Version PR
+consumes these files and updates package versions and changelogs; it does not publish packages.
diff --git a/.changeset/config.json b/.changeset/config.json
index 406f683d..36b43819 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -7,7 +7,7 @@
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
- "ignore": ["@inkcre/ext-mail", "@inkcre/ext-memos", "@inkcre/ext-twitter"],
+ "ignore": [],
"privatePackages": {
"version": true,
"tag": false
diff --git a/.changeset/interaction-quality.md b/.changeset/interaction-quality.md
deleted file mode 100644
index 37df82c7..00000000
--- a/.changeset/interaction-quality.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@inkcre/client-web': patch
-'@inkcre/core': patch
----
-
-按任务精简 Settings、Peer 与 Extension 界面,统一页面和浏览器标题,补齐场景化加载、刷新失败保留、保存与取消的动作反馈。修正重复检索、任务不存在及日志读取失败的可观察结果,保留失败后的恢复入口。
diff --git a/.changeset/schema-forms-tabs.md b/.changeset/schema-forms-tabs.md
deleted file mode 100644
index af222f8c..00000000
--- a/.changeset/schema-forms-tabs.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-'@inkcre/client-web': patch
-'@inkcre/core': patch
-'@inkcre/ext-dev-utils': patch
----
-
-Source 与 Peer 配置使用可视化表单,保留完整 JSON 编辑模式;Peers 页面可确认删除离线 Peer 并清理其扩展启用记录。Extension 浏览与版本选择交由 Registry,Web 接收精确版本并确认安装;Web 与 Module Federation 扩展统一升级正式 UI 2.2.0,Peer AI 凭据在 schema 中标记为密码输入。
diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml
deleted file mode 100644
index b02e41dc..00000000
--- a/.github/workflows/package-release.yml
+++ /dev/null
@@ -1,57 +0,0 @@
-name: Package version planning
-
-on:
- push:
- branches:
- - main
-
-permissions:
- contents: read
-
-concurrency:
- group: package-version-main
- cancel-in-progress: false
-
-jobs:
- reconcile:
- name: Reconcile application and SDK versions
- permissions:
- contents: write
- packages: read
- pull-requests: write
- runs-on: ubuntu-latest
- steps:
- - name: Checkout main
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- fetch-depth: 0
-
- - name: Install the pinned pnpm release
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- with:
- version: 11.11.0
-
- - name: Set up Node
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version-file: package.json
- cache: pnpm
- registry-url: https://npm.pkg.github.com
- scope: '@inkcre'
-
- - name: Install the frozen workspace
- run: pnpm install --frozen-lockfile
- env:
- NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Create or update the Package Version PR
- id: changesets
- uses: changesets/action@8488615a623b1b9c987934bb89eae8af6a946ac1 # v2.1.1
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- version-script: pnpm release:version
- pr-title: 'chore(packages): version pending releases'
- commit-message: 'chore(packages): version pending releases'
- pr-base-branch: main
- create-github-releases: false
- push-git-tags: false
diff --git a/apps/client-web/CHANGELOG.md b/apps/client-web/CHANGELOG.md
index 915d55eb..f4b85846 100644
--- a/apps/client-web/CHANGELOG.md
+++ b/apps/client-web/CHANGELOG.md
@@ -1,5 +1,20 @@
# @inkcre/client-web
+## 0.1.3
+
+### Patch Changes
+
+- 7307dda: 按任务精简 Settings、Peer 与 Extension 界面,统一页面和浏览器标题,补齐场景化加载、刷新失败保留、保存与取消的动作反馈。修正重复检索、任务不存在及日志读取失败的可观察结果,保留失败后的恢复入口。
+- 插件配置与业务状态在部署内共享;精确分发版本和启用意图分别保存在每个 Peer。Client Web 在同一插件入口管理各 Peer 的安装、版本和运行状态,并采用 Extension management v2。
+
+ 原生 SDK 不再提供全局插件版本与 enabled 数组。新版 MF 使用 0.4 SDK 的参与记录接口,历史 MF 需按其 SDK 范围运行或明确选择新版;不会自动改写旧版本选择。Python 与 MF 分别从自己的仓库发布,插件负责共享数据契约兼容。
+
+- 7307dda: Source 与 Peer 配置使用可视化表单,保留完整 JSON 编辑模式;Peers 页面可确认删除离线 Peer 并清理其扩展启用记录。Extension 浏览与版本选择交由 Registry,Web 接收精确版本并确认安装;Web 与 Module Federation 扩展统一升级正式 UI 2.2.0,Peer AI 凭据在 schema 中标记为密码输入。
+- Updated dependencies [7307dda]
+- Updated dependencies
+- Updated dependencies [7307dda]
+ - @inkcre/core@0.4.0
+
## 0.1.2
### Patch Changes
diff --git a/apps/client-web/package.json b/apps/client-web/package.json
index 576b9cbc..c6c7b498 100644
--- a/apps/client-web/package.json
+++ b/apps/client-web/package.json
@@ -1,6 +1,6 @@
{
"name": "@inkcre/client-web",
- "version": "0.1.2",
+ "version": "0.1.3",
"private": true,
"type": "module",
"scripts": {
@@ -12,7 +12,7 @@
},
"dependencies": {
"@inkcre/core": "workspace:*",
- "@inkcre/extension-runtime-client-web": "github:InKCre/ext-reg#57dcd80289285620a1fd27a0a48027e56c4ce7b1&path:/runtimes/client-web",
+ "@inkcre/extension-runtime-client-web": "github:InKCre/ext-reg#ff599f32e8d6e186974cf342a95fee306d9e60f8&path:/runtimes/client-web",
"@inkcre/ui-web": "2.2.1",
"@module-federation/runtime": "^0.21.4",
"@supabase/postgrest-js": "^2.84.0",
diff --git a/apps/client-web/src/components/extension/extensionCard/extensionCard.md b/apps/client-web/src/components/extension/extensionCard/extensionCard.md
index 7ef01361..fdb7e70d 100644
--- a/apps/client-web/src/components/extension/extensionCard/extensionCard.md
+++ b/apps/client-web/src/components/extension/extensionCard/extensionCard.md
@@ -1,28 +1,21 @@
# ExtensionCard
-A component that displays an installed Extension and opens focused Peer selection for enablement changes.
+Displays one deployment Extension and its independent native participation on each Peer.
-## Props
+The `extension` prop supplies shared identity and configuration; `participations` supplies exact
+Peer/distribution/version selections and enabled intent. `peers`, `livePeerIds` and `currentPeerId`
+identify operation targets and determine local runtime, remote management or offline intent routing.
+`peerSelectionDisabled` prevents mutation while target discovery is unavailable.
-- `extension` (InstalledExtension, required): the canonical installed row
-- `peers` (Peer[], required): available Peers, including this browser
-- `livePeerIds` (ReadonlySet, required): server-evaluated online Peers used for management routing
-- `currentPeerId` (string, required): identifies this browser's running Host
-- `peerSelectionDisabled` (boolean): blocks actions while the Peer list is unavailable
+Each Peer row keeps version selection, enablement, actual running version and errors together.
+The component obtains actual runtime status from the host rather than inferring it from enabled
+intent. A selection change affects only that row. Removing a disabled participation retains shared
+configuration and business data. An unobserved remote Host is not assigned a format from its name.
-## Emits
+`updated` emits the shared configuration row after a successful save. `participationChanged` asks the
+parent to reload native choices after a successful targeted mutation. The running browser module
+can contribute its own setup dialog; disabling that browser closes its contribution first.
-- `updated`: Emitted with the canonical row after Peer, configuration, or version changes
-- `uninstalled`: Emitted after the canonical row is removed
-
-## Features
-
-- Display canonical Extension Name, exact version, and optional nickname
-- Choose one or more eligible Peers in an enable or disable dialog. Each selected Peer uses the existing current Host, remote Host, or durable-intent path. Partial failures remain visible and trigger a fresh installed-row read; no batch rollback or automatic retry occurs.
-- Mount an Extension-owned setup contribution from this browser's running Web Distribution
-- Keep setup availability independent of the Peer selection dialog; disabling this browser closes its setup surface first
-- Link the exact installed release's available global, Core, and Web documentation even when the extension is disabled or has no browser distribution; distinguish missing documentation from failed discovery
-- Edit extension configuration via JSON editor in a dialog
-- Select a published Registry Release and change the exact shared version only while every Peer is disabled
-- Auto-formats configuration as JSON for easier editing
-- Prevents uninstall while any Peer remains enabled
+Configuration editing offers schemas with their exact Peer/distribution/version source, plus JSON
+editing without a schema. Documentation opens for an explicitly selected participation and follows
+Registry-returned links. Missing documentation does not block runtime operations.
diff --git a/apps/client-web/src/components/extension/extensionCard/extensionCard.scss b/apps/client-web/src/components/extension/extensionCard/extensionCard.scss
index 791ec52c..4702eae9 100644
--- a/apps/client-web/src/components/extension/extensionCard/extensionCard.scss
+++ b/apps/client-web/src/components/extension/extensionCard/extensionCard.scss
@@ -62,6 +62,16 @@
margin: 0;
}
+ &__peer-row {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: center;
+ gap: sys-var(space, sm);
+ padding-block: sys-var(space, sm);
+ border-top: 1px solid sys-var(color, border, subtle);
+ }
+
&__peer-options {
display: flex;
flex-direction: column;
diff --git a/apps/client-web/src/components/extension/extensionCard/extensionCard.ts b/apps/client-web/src/components/extension/extensionCard/extensionCard.ts
index 9900293a..e6db1ce8 100644
--- a/apps/client-web/src/components/extension/extensionCard/extensionCard.ts
+++ b/apps/client-web/src/components/extension/extensionCard/extensionCard.ts
@@ -1,9 +1,10 @@
import { type PropType } from 'vue'
-import type { InstalledExtension, Peer } from '@inkcre/core'
+import type { InstalledExtension, ExtensionParticipation, Peer } from '@inkcre/core'
// --- Props ---
export const extensionCardProps = {
extension: { type: Object as PropType, required: true },
+ participations: { type: Array as PropType, required: true },
peers: { type: Array as PropType, required: true },
livePeerIds: { type: Object as PropType>, required: true },
currentPeerId: { type: String, required: true },
@@ -13,5 +14,5 @@ export const extensionCardProps = {
// --- Emits ---
export const extensionCardEmits = {
updated: (_extension: InstalledExtension) => true,
- uninstalled: () => true,
+ participationChanged: () => true,
} as const
diff --git a/apps/client-web/src/components/extension/extensionCard/extensionCard.vue b/apps/client-web/src/components/extension/extensionCard/extensionCard.vue
index cb60bee5..bee83552 100644
--- a/apps/client-web/src/components/extension/extensionCard/extensionCard.vue
+++ b/apps/client-web/src/components/extension/extensionCard/extensionCard.vue
@@ -1,5 +1,5 @@
-
-
+
+
{{ t('extension.usedOnPeers') }}
+
+
+
{{ peer.name }} · {{ t('extension.currentBrowser') }}
+
+ {{
+ pin
+ ? `${distributionLabel(pin.distribution)} · ${pin.version}`
+ : t('extension.notInstalled')
+ }}
+
+
{{ statusText(peer.id) }}
+
+ {{ errors[peer.id] || status?.error }}
+
+
+
+
-
-
- {{ t('extension.uninstallDisabled') }}
-
- {{ t('extension.uninstallConfirm') }}
- {{ operationError }}
-
-
-
+ {{ t('extension.documentationMissing') }}
+
-
-
-
-
- {{ t('extension.desiredStateExplanation') }}
-
-
- {{ error }}
+
+
+
+
+ {{ errors[selectedPeer.id] }}
-
-
-
-
-
-
- !open && closeSetup()"
- >
-
+ :is-loading="!!busyPeer"
+ :disabled="!versionModel || versionLoading || !!versionError"
+ @click="saveVersion"
+ />
-
+
- {{ operationError }}
-
-
- {{ configError }}
+
-
+ :disabled="!canSaveConfig"
+ @click="saveConfig"
+ />
-
-
-
-
-
-
- {{ operationError }}
-
-
+ v-model="setupOpen"
+ :title="t('extension.setupTitle', { name: extension.nickname ?? extension.name })"
+ :show-confirm="false"
+ :show-cancel="false"
+ :close-on-scrim="false"
+ @update:model-value="(open) => !open && closeSetup()"
+ >
+
-
diff --git a/apps/client-web/src/components/peer/peerCard/peerCard.vue b/apps/client-web/src/components/peer/peerCard/peerCard.vue
index 5344053d..0e31206d 100644
--- a/apps/client-web/src/components/peer/peerCard/peerCard.vue
+++ b/apps/client-web/src/components/peer/peerCard/peerCard.vue
@@ -3,7 +3,13 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { InkButton, InkDialog, InkInput, type JsonEditorValidation } from '@inkcre/ui-web'
import SchemaConfigEditor from '@/components/schemaConfigEditor/schemaConfigEditor.vue'
-import { configStore, ExtensionModel, Peer, PeerConfigSchema, PeerManager } from '@inkcre/core'
+import {
+ configStore,
+ ExtensionParticipationModel,
+ Peer,
+ PeerConfigSchema,
+ PeerManager,
+} from '@inkcre/core'
import { peerCardEmits, peerCardProps } from './peerCard'
import { currentWebPeer } from '@/core'
@@ -87,9 +93,10 @@ const onConfirmDelete = async () => {
emit('updated')
return
}
- const extensions = await ExtensionModel.list()
+ const extensions = await ExtensionParticipationModel.list(props.peer.id)
for (const extension of extensions) {
- if (extension.enabled.includes(props.peer.id)) await extension.disablePeer(props.peer.id)
+ if (extension.enabled) await extension.setEnabled(false)
+ await (await ExtensionParticipationModel.get(props.peer.id, extension.name))?.remove()
}
await props.peer.delete()
deleteOpen.value = false
diff --git a/apps/client-web/src/core.ts b/apps/client-web/src/core.ts
index d937eede..f579c142 100644
--- a/apps/client-web/src/core.ts
+++ b/apps/client-web/src/core.ts
@@ -167,6 +167,7 @@ export function initializeExtensionHost(): ClientExtensionManager {
hostSdk: { name: '@inkcre/core', version: corePackageJson.version },
})
extensionHost = new ExtensionManager({
+ peerId: configStore.metaConfig.INKCRE_PEER_ID,
releases: extensionRegistry,
moduleFederation,
})
diff --git a/apps/client-web/src/extension-peer-control.ts b/apps/client-web/src/extension-peer-control.ts
index a0c79a26..bb604716 100644
--- a/apps/client-web/src/extension-peer-control.ts
+++ b/apps/client-web/src/extension-peer-control.ts
@@ -1,8 +1,17 @@
-import { ExtensionModel, Peer, type InstalledExtension } from '@inkcre/core'
+import {
+ ExtensionModel,
+ ExtensionParticipationModel,
+ Peer,
+ type ExtensionParticipation,
+ type ExtensionRuntimeStatus,
+ type NativeDistribution,
+} from '@inkcre/core'
import {
EXTENSION_MANAGEMENT_CAPABILITY,
manageExtensionOnPeer,
+ readExtensionPeerStatus,
type ExtensionManager,
+ type RegistryReleaseReader,
} from '@inkcre/extension-runtime-client-web'
export { EXTENSION_MANAGEMENT_CAPABILITY }
@@ -27,6 +36,31 @@ export function extensionPeerControlMode(
: 'desired-state'
}
+export async function observeExtensionPeer(input: {
+ name: string
+ peer: Peer
+ currentPeerId: string
+ livePeerIds: ReadonlySet
+ manager: ExtensionManager
+}): Promise<{
+ supported_distributions: NativeDistribution[]
+ status: ExtensionRuntimeStatus | null
+}> {
+ const mode = extensionPeerControlMode(input.peer, input.currentPeerId, input.livePeerIds)
+ if (mode === 'current-runtime') {
+ return {
+ supported_distributions: ['module_federation'],
+ status: await input.manager.getStatus(input.name, input.currentPeerId),
+ }
+ }
+ if (mode === 'desired-state') return { supported_distributions: [], status: null }
+ const response = await readExtensionPeerStatus(input.peer.id, input.name)
+ return {
+ supported_distributions: response.supported_distributions,
+ status: response.extensions.find((row) => row.name === input.name) ?? null,
+ }
+}
+
export async function setExtensionPeerEnabled(input: {
name: string
peer: Peer
@@ -34,7 +68,7 @@ export async function setExtensionPeerEnabled(input: {
livePeerIds: ReadonlySet
enabled: boolean
manager: ExtensionManager
-}): Promise {
+}): Promise {
const mode = extensionPeerControlMode(input.peer, input.currentPeerId, input.livePeerIds)
if (mode === 'current-runtime') {
return input.enabled
@@ -42,15 +76,51 @@ export async function setExtensionPeerEnabled(input: {
: input.manager.disable(input.name, input.currentPeerId)
}
if (mode === 'desired-state') {
- const extension = await ExtensionModel.get(input.name)
- if (!extension) throw new Error(`Extension ${input.name} is not installed.`)
- return input.enabled
- ? extension.enablePeer(input.peer.id)
- : extension.disablePeer(input.peer.id)
+ const participation = await ExtensionParticipationModel.get(input.peer.id, input.name)
+ if (!participation) throw new Error('Select a version for this Peer first.')
+ return participation.setEnabled(input.enabled)
}
-
return manageExtensionOnPeer(input.peer.id, {
action: input.enabled ? 'enable' : 'disable',
extension: input.name,
})
}
+
+export async function selectExtensionPeerVersion(input: {
+ name: string
+ nickname: string
+ releases: RegistryReleaseReader
+ version: string
+ distribution: NativeDistribution
+ peer: Peer
+ currentPeerId: string
+ livePeerIds: ReadonlySet
+ manager: ExtensionManager
+}): Promise {
+ const mode = extensionPeerControlMode(input.peer, input.currentPeerId, input.livePeerIds)
+ if (mode === 'current-runtime') {
+ if (input.distribution !== 'module_federation')
+ throw new Error('This Peer supports Module Federation.')
+ return input.manager.install({ name: input.name, version: input.version }, input.currentPeerId)
+ }
+ if (mode === 'remote-host') {
+ return manageExtensionOnPeer(input.peer.id, {
+ action: 'select',
+ extension: input.name,
+ distribution: input.distribution,
+ version: input.version,
+ })
+ }
+ await input.releases.getRelease(input.name, input.version, true, input.distribution)
+ const previous = await ExtensionParticipationModel.get(input.peer.id, input.name)
+ if (previous?.distribution !== input.distribution)
+ throw new Error('Connect this Peer to verify its supported distributions.')
+ await ExtensionModel.install({ name: input.name, nickname: input.nickname })
+ return ExtensionParticipationModel.select({
+ name: input.name,
+ peerId: input.peer.id,
+ distribution: input.distribution,
+ version: input.version,
+ expectedVersion: previous.version,
+ })
+}
diff --git a/apps/client-web/src/locales/messages/en.json b/apps/client-web/src/locales/messages/en.json
index 2461d6c9..ddabf354 100644
--- a/apps/client-web/src/locales/messages/en.json
+++ b/apps/client-web/src/locales/messages/en.json
@@ -159,7 +159,7 @@
"documentationUnavailable": "Documentation is temporarily unavailable.",
"registryUnavailable": "The Registry is unavailable",
"releaseUnavailable": "Unable to load this Extension",
- "invalidInstallLink": "This installation link needs one Extension ID and one exact version.",
+ "invalidInstallLink": "This installation link needs an Extension ID, distribution and exact version.",
"connectBeforeInstall": "Connect your deployment in",
"connectDeployment": "Connect your deployment",
"connectDeploymentDescription": "Add its connection in Settings to manage Extensions here.",
@@ -181,8 +181,8 @@
"disableOnPeers": "Disable…",
"enablePeerTitle": "Enable {name} on Peers",
"disablePeerTitle": "Disable {name} on Peers",
- "enableSelected": "Enable selected",
- "disableSelected": "Disable selected",
+ "enableSelected": "Enable",
+ "disableSelected": "Disable",
"currentBrowser": "This browser",
"desiredStateBrief": "Waiting for Peer sync",
"peerListUnavailable": "The Peer list could not be loaded: {error}",
@@ -191,7 +191,25 @@
"noneInstalledDescription": "Browse the Registry to add an Extension.",
"enabledPeerCount": "Enabled on {count} Peer(s).",
"uninstallDisabled": "Disable every Peer before uninstalling or changing the shared version.",
- "uninstallConfirm": "Uninstall removes this extension from the deployment. Its configuration will be deleted."
+ "uninstallConfirm": "Uninstall removes this extension from the deployment. Its configuration will be deleted.",
+ "usedOnPeers": "Used on Peers",
+ "restartRequired": "Restart required",
+ "runningVersion": "Running {version}",
+ "needsVersionSelection": "Select a distribution",
+ "pendingApplication": "Pending application",
+ "startFailed": "Unable to start",
+ "notRunning": "Not running",
+ "disabled": "Disabled",
+ "noDistributionRelease": "No published versions for this distribution.",
+ "distributionUnknown": "Distribution unresolved",
+ "notInstalled": "Not installed",
+ "removeFromPeer": "Remove",
+ "distribution": "Distribution",
+ "configSchemaSource": "Configuration form",
+ "jsonConfig": "JSON",
+ "targetPeer": "Peer",
+ "noSupportedPeer": "No connected Peer supports this distribution.",
+ "runtimeUnknown": "Runtime status unavailable"
},
"job": {
"loadFailed": "Unable to load this job.",
diff --git a/apps/client-web/src/locales/messages/zh-CN.json b/apps/client-web/src/locales/messages/zh-CN.json
index ae168921..2a0dbc0d 100644
--- a/apps/client-web/src/locales/messages/zh-CN.json
+++ b/apps/client-web/src/locales/messages/zh-CN.json
@@ -160,7 +160,7 @@
"documentationUnavailable": "文档暂时不可用。",
"registryUnavailable": "Registry 暂时不可用",
"releaseUnavailable": "无法读取此扩展",
- "invalidInstallLink": "安装链接需要包含一个扩展 ID 和一个精确版本。",
+ "invalidInstallLink": "安装链接需要插件 ID、分发和精确版本。",
"connectBeforeInstall": "请先在此处连接自己的部署:",
"connectDeployment": "连接自己的部署",
"connectDeploymentDescription": "在设置中填入连接信息,即可在此管理扩展。",
@@ -182,8 +182,8 @@
"disableOnPeers": "停用…",
"enablePeerTitle": "选择要启用 {name} 的 Peer",
"disablePeerTitle": "选择要停用 {name} 的 Peer",
- "enableSelected": "在所选 Peer 上启用",
- "disableSelected": "在所选 Peer 上停用",
+ "enableSelected": "启用",
+ "disableSelected": "停用",
"currentBrowser": "当前浏览器",
"desiredStateBrief": "等待 Peer 同步",
"peerListUnavailable": "无法加载 Peer 列表:{error}",
@@ -192,7 +192,25 @@
"noneInstalledDescription": "浏览 Registry,选择要安装的扩展。",
"enabledPeerCount": "已在 {count} 个 Peer 上启用。",
"uninstallDisabled": "卸载或变更共享版本前,必须先禁用所有 Peer。",
- "uninstallConfirm": "卸载会从此部署移除扩展,并删除其配置。"
+ "uninstallConfirm": "卸载会从此部署移除扩展,并删除其配置。",
+ "usedOnPeers": "使用 Peer",
+ "restartRequired": "需要重启",
+ "runningVersion": "正在运行 {version}",
+ "needsVersionSelection": "请选择分发",
+ "pendingApplication": "待应用",
+ "startFailed": "启动失败",
+ "notRunning": "未运行",
+ "disabled": "已停用",
+ "noDistributionRelease": "此分发暂无已发布版本。",
+ "distributionUnknown": "分发待解析",
+ "notInstalled": "未安装",
+ "removeFromPeer": "移除",
+ "distribution": "分发",
+ "configSchemaSource": "配置表单",
+ "jsonConfig": "JSON",
+ "targetPeer": "Peer",
+ "noSupportedPeer": "当前已连接的 Peer 不支持此分发。",
+ "runtimeUnknown": "运行状态未知"
},
"job": {
"loadFailed": "无法读取此任务。",
diff --git a/apps/client-web/src/views/extensions/extensions.vue b/apps/client-web/src/views/extensions/extensions.vue
index 4d5773f8..c593ea3d 100644
--- a/apps/client-web/src/views/extensions/extensions.vue
+++ b/apps/client-web/src/views/extensions/extensions.vue
@@ -2,10 +2,19 @@
import { computed, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
-import { InkButton, InkSkeleton, InkPlaceholder } from '@inkcre/ui-web'
+import { InkButton, InkSkeleton, InkPlaceholder, InkDropdown } from '@inkcre/ui-web'
import { usePageObjectTitle } from '@/composables/use-page-object-title'
-import { configStore, Peer, PeerManager, type InstalledExtension } from '@inkcre/core'
+import {
+ configStore,
+ Peer,
+ PeerManager,
+ ExtensionParticipationModel,
+ type ExtensionParticipation,
+ type NativeDistribution,
+ type InstalledExtension,
+} from '@inkcre/core'
import type { ReleaseRecord } from '@inkcre/extension-runtime-client-web'
+import { observeExtensionPeer, selectExtensionPeerVersion } from '@/extension-peer-control'
import extensionCard from '@/components/extension/extensionCard/extensionCard.vue'
import {
getExtensionHost,
@@ -21,6 +30,15 @@ const currentPeerId = configStore.metaConfig.INKCRE_PEER_ID
const peers = ref([])
const livePeerIds = ref>(new Set())
const extensions = ref([])
+const participations = ref([])
+const installPeerId = ref(currentPeerId)
+const installPeers = ref([])
+const installPeerOptions = computed(() =>
+ installPeers.value.map((peer) => ({ label: peer.name, value: peer.id }))
+)
+const installDistribution = computed(() =>
+ route.query.distribution === 'python' ? 'python' : 'module_federation'
+)
const peersLoading = ref(false)
const extensionsLoading = ref(false)
const extensionError = ref(null)
@@ -86,7 +104,12 @@ async function refreshExtensions(): Promise {
extensionsLoading.value = true
extensionError.value = null
try {
- extensions.value = await getExtensionHost().list()
+ const [shared, selections] = await Promise.all([
+ getExtensionHost().list(),
+ ExtensionParticipationModel.list(),
+ ])
+ extensions.value = shared
+ participations.value = selections
} catch (error) {
extensionError.value = error instanceof Error ? error.message : String(error)
} finally {
@@ -119,14 +142,44 @@ async function loadInstallRelease(): Promise {
if (!installRequested.value) return
const name = route.query.install
const version = route.query.version
- if (typeof name !== 'string' || typeof version !== 'string') {
+ if (
+ typeof name !== 'string' ||
+ typeof version !== 'string' ||
+ !['python', 'module_federation'].includes(String(route.query.distribution))
+ ) {
installError.value = t('extension.invalidInstallLink')
return
}
installLoading.value = true
try {
- const release = await getExtensionRegistry().getRelease(name, version, true)
- if (request === releaseRequest) installRelease.value = release
+ const release = await getExtensionRegistry().getRelease(
+ name,
+ version,
+ true,
+ installDistribution.value
+ )
+ if (request === releaseRequest) {
+ installRelease.value = release
+ await refreshPeers()
+ const observations = await Promise.all(
+ availablePeers.value.map(async (peer) => {
+ try {
+ const result = await observeExtensionPeer({
+ name,
+ peer,
+ currentPeerId,
+ livePeerIds: livePeerIds.value,
+ manager: getExtensionHost(),
+ })
+ return result.supported_distributions.includes(installDistribution.value) ? peer : null
+ } catch {
+ return null
+ }
+ })
+ )
+ if (request === releaseRequest)
+ installPeers.value = observations.filter((peer): peer is Peer => peer !== null)
+ }
} catch (error) {
if (request === releaseRequest) {
installError.value = error instanceof Error ? error.message : String(error)
@@ -138,13 +191,24 @@ async function loadInstallRelease(): Promise {
async function installReleaseInDeployment(): Promise {
const release = installRelease.value
- if (!release || installing.value || installedMatch.value || !connected.value) return
+ if (!release || installing.value || !connected.value) return
+ const peer = installPeers.value.find((peer) => peer.id === installPeerId.value)
+ if (!peer) return
installing.value = true
installError.value = null
try {
- updateExtension(
- await getExtensionHost().install({ name: release.name, version: release.version })
- )
+ await selectExtensionPeerVersion({
+ name: release.name,
+ nickname: release.nickname,
+ releases: getExtensionRegistry(),
+ version: release.version,
+ distribution: installDistribution.value,
+ peer,
+ currentPeerId,
+ livePeerIds: livePeerIds.value,
+ manager: getExtensionHost(),
+ })
+ await refreshExtensions()
await router.replace('/extensions')
} catch (error) {
installError.value = error instanceof Error ? error.message : String(error)
@@ -160,7 +224,11 @@ function updateExtension(updated: InstalledExtension): void {
: [...extensions.value, updated].sort((left, right) => left.name.localeCompare(right.name))
}
-watch(() => [route.query.install, route.query.version], loadInstallRelease, { immediate: true })
+watch(
+ () => [route.query.install, route.query.version, route.query.distribution],
+ loadInstallRelease,
+ { immediate: true }
+)
onMounted(() => {
if (connected.value) {
@@ -225,10 +293,16 @@ onMounted(() => {
.join(' · ')
}}
-
- {{ t('extension.installedVersion', { version: installedMatch.version }) }}
+
+
+ {{ t('extension.noSupportedPeer') }}
-
+
{{ t('extension.connectBeforeInstall') }}
{{ t('common.settings') }}
@@ -239,10 +313,14 @@ onMounted(() => {
{{ installError }}
@@ -305,12 +383,13 @@ onMounted(() => {
v-for="extension in extensions"
:key="extension.name"
:extension="extension"
+ :participations="participations.filter((row) => row.name === extension.name)"
:peers="availablePeers"
:live-peer-ids="livePeerIds"
:current-peer-id="currentPeerId"
:peer-selection-disabled="peersLoading || !!peerError"
@updated="updateExtension"
- @uninstalled="refreshExtensions"
+ @participation-changed="refreshExtensions"
/>
{{ t('extension.installedUnavailable') }}
diff --git a/apps/client-webext/CHANGELOG.md b/apps/client-webext/CHANGELOG.md
index 3ba8127d..f552ef4e 100644
--- a/apps/client-webext/CHANGELOG.md
+++ b/apps/client-webext/CHANGELOG.md
@@ -1,5 +1,14 @@
# @inkcre/client-webext
+## 0.1.3
+
+### Patch Changes
+
+- Updated dependencies [7307dda]
+- Updated dependencies
+- Updated dependencies [7307dda]
+ - @inkcre/core@0.4.0
+
## 0.1.2
### Patch Changes
diff --git a/apps/client-webext/package.json b/apps/client-webext/package.json
index fa11129e..6ce54332 100644
--- a/apps/client-webext/package.json
+++ b/apps/client-webext/package.json
@@ -1,6 +1,6 @@
{
"name": "@inkcre/client-webext",
- "version": "0.1.2",
+ "version": "0.1.3",
"private": true,
"description": "InKCre's Web extension",
"type": "module",
diff --git a/docs/30-unit-tdd/native-extension-runtime.md b/docs/30-unit-tdd/native-extension-runtime.md
index a3e94fcc..af24093b 100644
--- a/docs/30-unit-tdd/native-extension-runtime.md
+++ b/docs/30-unit-tdd/native-extension-runtime.md
@@ -12,15 +12,15 @@ Resolver/content producer semantics belong to [Info-Base](info-base.md). Registr
artifact delivery belong to
[First-party Extension Delivery](../40-deployment/native-extension-delivery.md).
-There is one native format: an Extension Release may associate a Module Federation distribution.
-The Host consumes that association directly. `@inkcre/extension-runtime-client-web`, maintained in
+The browser supports the Module Federation native distribution. Registry v2 resolves an exact
+`(name, module_federation, version)` release; other Peers independently select their native distributions. `@inkcre/extension-runtime-client-web`, maintained in
ext-reg, owns the Web Host and Registry consumers. `@inkcre/core` owns the shared models, Peer
transport, and browser configuration. There is no generic target matcher or cross-format artifact
manifest.
## Producer and Host Contract
-Each producer is an independent build package; the unified Extension release plan supplies its version. Its default export preserves the optional
+Each producer is an independent build package; its package version owns its MF release. Its default export preserves the optional
native lifecycle methods `initialize`, `activate`, `deactivate`, and `dispose`; host-consumed
capabilities come from its package entry rather than playground bootstrap code. Extension code
imports `@inkcre/core` directly.
@@ -50,36 +50,33 @@ passes directly to the current native Module Federation implementation.
## Durable State and Peer Intent
-The Runtime uses `ExtensionModel` from the shared `@inkcre/core` instance for installed state,
-configuration, and per-Peer enabled intent. There is no application-owned `ExtensionStatePort` or
-second PostgREST adapter. The application chooses the targeted Peers for each enable or disable
-action: current-runtime operations use its local Host, online remote Hosts receive management
-commands, and offline enablement changes update desired state through the SDK model. The browser
-uses `PeerManager.listLive()` for this routing decision, matching the Peers page rather than
-comparing a lease timestamp with the browser clock. Selecting
-several Peers performs independent per-Peer operations, preserves successful changes when another
-Peer fails, then reads the canonical installed row before offering another decision. It does not
-invent a batch transaction or retry an ambiguous remote outcome.
-
-`listAdvertisedExtensionManagementPeers` and `manageExtensionOnPeer` in the Runtime own the shared
-management capability contract. The latter delegates once to the exact Peer through the SDK's
-`PeerManager`, validates the installed-row response, and preserves unknown outcomes. Consumers do
-not retry mutations or choose a replacement Peer automatically. Response bodies are not included
-in management errors because configuration validation may echo credentials.
-
-Installed state records exact name and version plus the set of enabled Peer IDs. Registry Web owns
-catalog search and version browsing; its link returns only a name and exact version to the
-client-web Extensions page. Opening the link reads the published Release from this deployment's
-configured Registry and shows a confirmation, but does not write installed state. Confirmation
-uses the deployment-scoped Web Runtime install operation without selecting a Peer or requiring a
-particular Host distribution. A Python-only Release can therefore be installed from the Web
-interface. Installation does not enable either Host, and the shared version still affects every
-Peer. Reopening an installed version does not silently change it; version changes remain an
-explicit management operation.
-
-Version change and uninstall are refused while any Peer remains enabled or a local runtime is
-running. Startup reads canonical installed state and starts only entries enabled for the current
-Peer. Shutdown stops volatile runtimes without changing durable enabled intent.
+The shared `ExtensionModel` owns one deployment identity, display name and configuration. Business
+state remains deployment-scoped and is never cleared by native version changes. The
+`ExtensionParticipationModel` owns each `(name, peer_id)` selection: exact distribution/version,
+enabled intent and configuration schema from that exact selection. No global package version or
+shared enabled array remains writable.
+
+The Extension card displays a single plugin with a row for each Peer. Installation and version
+changes explicitly target that row. The current browser uses its local Host; an online remote Peer
+uses `core.extension.management.v2`; offline operations update an existing exact participation.
+A new remote installation requires a real supported-distribution observation. Peer names and
+application versions do not imply format support. Missing MF releases remain unavailable choices;
+a Python-only plugin does not get a failing browser enable action.
+
+Management responses contain supported distributions and actual loaded versions, running state,
+restart requirements and errors. Database enabled intent is not proof that a sleeping Peer is running.
+The consumer sends each mutation once to its exact target and preserves unknown outcomes. It does
+not retry mutations on another Peer. Configuration forms identify the selected Peer/distribution/
+version that provided their schema; shared configuration has no global last-started schema.
+
+Registry installation links carry `name`, `distribution` and exact `version`. Opening a link only
+reads the release and presents the target Peer choice; confirmation persists that exact target's
+selection without enabling it. Startup reads only current-Peer enabled selections and never follows
+latest. Legacy records with unresolved distribution retain their exact version until the upgraded
+Host resolves its own native format. A missing release requires an explicit replacement selection.
+
+Removing a participation requires it to be disabled and does not remove the plugin's shared identity,
+configuration, authorization state, Sources or info-base content. Another Peer's selection is unchanged.
## Documentation and Memos Connection
@@ -97,7 +94,7 @@ runtime assumption. The contribution obtains the complete server URL from `memos
it does not read Peer configuration or construct a Memos route. Address-read failures preserve the
saved token and enabled state. Opening, selecting, and refreshing only read state. Unknown mutation
outcomes require refreshing before another attempt. The PAT is masked by default; a normal help
-link opens the exact MF release's connection guide.
+link opens the current browser participation's exact MF release connection guide.
## Lifecycle and Compensation
@@ -112,9 +109,9 @@ start phase fails, it compensates through the available reverse lifecycle and pr
original and cleanup failures when cleanup also fails. Stop deactivates only an active module and
then disposes it; disposal releases the loaded runtime.
-Enable starts the exact runtime before adding the current Peer to durable enabled intent. If
+Enable starts the exact runtime before setting that participation’s enabled intent. If
persistence fails, the Host stops and removes that runtime. If the persisted version changed during
-enable, the Host rolls back the just-added Peer intent and requires a retry against the new exact
+enable, the Host rolls back that participation’s enabled intent and requires a retry against the new exact
Release.
Disable reverses the order: it stops the runtime before removing durable enabled intent. If state
@@ -125,7 +122,7 @@ for inspection, and reports an aggregate after attempting the remaining entries.
## Invariants
-- Canonical installed/enabled state is accessed through the shared SDK's `ExtensionModel`.
+- Shared identity/configuration and per-Peer native selection have separate SDK models.
- Compatibility is proven before native executable fetch.
- The exact Release and native manifest association remain stable through one start attempt.
- Durable enabled intent and volatile runtime are reconciled with explicit compensation.
diff --git a/docs/40-deployment/native-extension-delivery.md b/docs/40-deployment/native-extension-delivery.md
index 00e4fc69..b512c553 100644
--- a/docs/40-deployment/native-extension-delivery.md
+++ b/docs/40-deployment/native-extension-delivery.md
@@ -1,55 +1,158 @@
# First-party Extension Delivery
-This document owns Web native build delivery. Host runtime contracts belong to
-[Native Extension Runtime](../30-unit-tdd/native-extension-runtime.md). Each `extensions/*/package.json`
-with `inkcre.module_federation` owns its canonical identity, nickname and compatible `@inkcre/core`
-range. Its committed package version is development metadata; the Core release plan generates the
-exact shared Release version in an isolated build checkout.
+This document owns the operational Release and Registry delivery contract for first-party client-web
+Extensions. Extension lifecycle and Host internals belong to
+[Native Extension Runtime](../30-unit-tdd/native-extension-runtime.md). A producer is an
+`extensions/*/package.json` declaring `inkcre.module_federation`; its package version is the
+exact Registry Release version, and its `inkcre` metadata owns the canonical Extension identity and
+compatible `@inkcre/core` Host SDK range.
-## One Release Decision
+## Release Intent and Versioning
-Core's first-party Extension release workflow owns version selection, fixed Core and Web source
-revisions, candidate retention and Registry publication. A native Web change requires a release
-fragment for the corresponding Extension in core-py. Select the Web source revision containing that
-change when assembling the Release. App and SDK packages continue using Changesets and the package
-Version PR; native producers are excluded from that version decision.
+Contributors record intent with `pnpm changeset`, selecting only affected independently releasable
+Extension packages. On a protected `main` push,
+[`.github/workflows/extension-release.yml`](../../.github/workflows/extension-release.yml) runs the
+**First-party Extension release** lifecycle:
-The Core workflow invokes the Web consumer after checking out its fixed source revision:
+- With pending changesets, Changesets Action runs `pnpm release:version` and creates or updates the
+ Extension Version PR. That PR owns generated package versions and changelogs; contributors do not
+ edit those outputs separately. The reconciliation step does not publish, create GitHub Releases,
+ or push tags.
+- After the Version PR is merged and no changesets remain, the candidate job checks out that exact
+ release revision, installs the frozen workspace, and builds `@inkcre/core` plus every
+ `extensions/*` producer itself. It saves the selected plan, original prepare descriptors, full ZIP
+ snapshots and SHA-256 digests as the immutable `first-party-extension-candidate` Actions artifact.
+ No Registry writes occur until that artifact is retained and checked.
+- Producers are discovered from package metadata. A published Release with an existing Module
+ Federation association is a historical no-op at candidate selection. Missing MF native coordinates become
+ fixed candidates; the publish job downloads this run's exact artifact, prepares them with their
+ saved provenance, uploads, publishes, and verifies every candidate from the public Registry.
+ It never consumes pull-request CI artifacts or rebuilds a publication candidate.
+
+The repository must enable **Allow GitHub Actions to create and approve pull requests** for
+Changesets to create its Version PR. GitHub combines these capabilities in one setting; this
+workflow only creates PRs and must never approve reviews. Default workflow-token permissions stay
+read-only, with write permissions confined to reconciliation. GitHub may require a maintainer to
+select **Approve workflows to run** on a token-created Version PR before its checks execute; do not
+replace this gate with an empty-check merge or a broader credential.
+
+Client checks, Pages production, and Pages preview are separate application lifecycles. They cannot
+publish first-party Extensions. Local development and local verification must never publish either.
+
+Python and Module Federation are independent native releases. Each producer owns
+`(extension name, distribution, version)` and publishes only that distribution through Registry v2.
+The Web workflow does not read Core source or a Core version plan. Python publication neither
+selects nor waits for MF, and MF publication neither selects nor waits for Python. Host SDK ranges
+remain native loading requirements; they do not describe cross-distribution business compatibility.
+
+A plugin owns compatibility of its shared configuration, state, Source/Job parameters and stored
+information. Changesets record MF changes even when no Python change is necessary. Published native
+coordinates and saved candidate bytes are immutable; repairs require a new native version.
+
+## Native SDK 0.4 cutover
+
+The Peer-participation protocol removes the SDK's global Extension version and enabled array.
+New first-party MF releases therefore require `@inkcre/core >=0.4.0 <0.5.0`; historical MF releases
+keep their existing declared range and are not silently repinned or declared compatible. Shared
+configuration and business data remain preserved when the user chooses a supported native release.
+
+This breaking cutover prepares the SDK and its in-repository consumers in the same reviewed change.
+The existing `pnpm release:version` command consumes the already-admitted UI intents and the native
+participation intent once, generating SDK 0.4 and independent MF patch releases. The resulting
+Version outputs are committed; no duplicate pending intent remains for the next reconciliation.
+This is the one-time delivery boundary for this cutover. Ordinary feature work continues to submit
+Changesets and use the automated Version PR described above.
+
+Required database checks normally resolve the protected Core `stable` artifact. During this cutover,
+`contracts/core-release.json` can admit a `canonical_candidate` with an existing canonical Core
+image digest, its protected-main source SHA and exact database contract revision. The consumer
+checks the image-owned manifest against both expected values and rejects tags, other registries,
+source mismatches and contract mismatches. This reference is populated only after the existing Core
+canonical artifact lane publishes it; a local or preview image is qualification evidence and cannot
+be entered as that canonical candidate. Omitting the entry continues to use stable. This admission
+does not move stable, deploy Core or migrate a production database.
+
+## Registry Authority and Secret Boundary
+
+Publication runs only in the protected GitHub `production` environment. The scoped bearer secret
+`INKCRE_EXTENSION_REGISTRY_TOKEN` belongs only to the first-party Extension publish job; it must not be
+copied to a file, exposed to Pages or checks, or used for protected delivery from a developer
+machine. The public Registry defaults to `https://registry.inkcre.dev`; operators may override only
+the endpoint through repository variable `INKCRE_EXTENSION_REGISTRY_URL`.
+
+The workflow records `source_repository`, `source_revision`, and a release-workflow `build_id` in
+the Module Federation distribution before upload. These Web-distribution provenance facts remain
+independent of Python release provenance.
+
+Hosted documentation is discovered separately for the exact Release. Browser code follows the
+absolute entry or snapshot URL returned by Registry and never derives a content hostname from the
+Registry origin, Extension identity, or snapshot ID. The first-party deployment currently uses a
+broad `*.inkcre.dev` DNS/TLS catch-all while Registry admits only
+`registry-docs-{snapshot}.inkcre.dev`; therefore DNS resolution or a successful TLS handshake is
+not documentation availability. A 421 response is a rejected Host, not a URL pattern for the Web
+client to repair or retry under another subdomain.
+
+## Failure and Recovery
+
+An upload timeout is an unknown outcome, not proof of a rejected snapshot: a Registry behind Heroku
+may finish after its router returns H12. The publisher makes a bounded attempt to complete the
+existing publish operation and read back the Release. A new MF-only Release remains publicly
+invisible while preparing, so recovery cannot rely on public GET alone. Authentication failures,
+immutable conflicts, blocked/yanked Releases and byte mismatches fail rather than becoming retries
+or successful skips.
+
+After a transient publication failure, use **Re-run failed jobs** or re-run only
+**Publish first-party MF distributions**. These partial reruns retain the successful candidate
+job's artifact and output, so publication downloads the same exact artifact ID. Do not use
+**Re-run all jobs**: GitHub removes the original artifacts before executing the new attempt.
+Candidate discovery rejects a missing artifact on any attempt after the first, before build or
+Registry writes. An existing candidate is restored and checked, never silently replaced.
+Both first publication and recovery verify the full public
+snapshot, including candidates whose association appeared after an earlier timeout. A later,
+unrelated main push may classify those historical Releases as no-ops; it does not claim to verify
+them against a new build.
+
+Candidates are retained for 90 days. Do not delete the artifact of an unfinished release or start
+a new run to replace its prepared provenance. An expired or lost candidate requires operator
+investigation using the original run and Registry state; a fresh build is not equivalent evidence.
+The same conservative stop applies if the first attempt failed before saving any candidate. After
+confirming that publication never ran, the operator may proceed through a new normal main run.
+If publication did run, recover the original candidate; do not use a new build or new run to replace
+its bytes or prepared provenance.
+The original ZIP digest and all-file verification count are written to the publish job summary.
+
+`node --test scripts/test-extension-publication.mjs` exercises the publisher over an isolated HTTP
+fault-injection endpoint: an MF-only upload returns 503 while the saved snapshot becomes available,
+publish initially reports incomplete objects, and recovery verifies the original ZIP even after
+local build output changes. It also proves that an existing selected association cannot bypass
+byte verification, immutable conflicts stop immediately, and invalid saved candidates cause no
+Registry writes. It also checks that a rerun without its saved candidate stops before rebuilding.
+This is a publisher regression check, not evidence of production R2 latency.
+
+## Snapshot and Verification
+
+Each producer retains the relative artifact base `base: './'`, emits
+`dist/client-web/mf-manifest.json`, and declares its Registry association in package metadata. The
+distribution verifier checks the manifest Remote entry and every synchronous or asynchronous
+shared/exposed JavaScript and CSS reference, then compares every file in the uploaded snapshot,
+including transitive chunks and generated metadata. The uploaded snapshot is self-contained and
+relocatable; publication does not invent a generic target descriptor or rewrite the producer
+manifest.
+
+Public executable responses use `Cache-Control: public, no-cache` and an ETag. Immutable Release
+bytes do not imply an immutable HTTP cache policy: browsers must revalidate Registry readability
+so a blocked Release stops being served. Verification also checks CORS, the trusted Registry origin,
+manifest structure, and the exact bytes of every referenced asset.
+
+The executable contract is
+[`scripts/verify-native-extension-distribution.mjs`](../../scripts/verify-native-extension-distribution.mjs).
+Verify locally without publishing:
```bash
-node scripts/build-native-extension-candidates.mjs consume-plan \
- --plan /absolute/path/to/plan.json --output /absolute/path/to/candidate
+pnpm --filter @inkcre/core build
+pnpm --filter './extensions/*' build
+node scripts/verify-native-extension-distribution.mjs inspect-local \
+ --package extensions/
/package.json \
+ --core-package packages/core/package.json \
+ --artifact-directory extensions//dist/client-web
```
-
-The plan contains `web_revision`, `build_id` and `candidates` selected by Core. Each candidate includes
-`directory`, canonical `name`, `version` and the Python `prepare` association. The consumer matches
-producer metadata by canonical name, generates all matching package versions, builds `@inkcre/core`
-and the selected MF producers, and checks the resulting snapshots. A candidate with no Web producer
-remains Python-only. A matching producer that cannot build or validate fails the whole assembly.
-
-The output `plan.json` retains Core's fields and adds `prepare.module_federation` and
-`module_federation: {file, sha256}` to matching candidates. ZIP paths are relative to the retained
-candidate directory. Provenance records the fixed Web SHA and Core workflow build ID. Existing
-candidate bytes cannot be overwritten by another capture.
-
-Core adds checked wheels, retains the complete candidate before any Registry write, prepares all
-associations once, uploads both formats and publishes once. Recovery restores that exact retained
-candidate rather than rebuilding it. Registry credentials belong only to the protected Core publish
-job. Web CI, Pages production, Pages preview and this local consumer never publish Extensions.
-
-## Snapshot Verification
-
-Producers retain `base: './'` and emit `dist/client-web/mf-manifest.json`. The existing
-[`distribution verifier`](../../scripts/verify-native-extension-distribution.mjs) checks the Remote
-entry, shared/exposed JavaScript and CSS references, Host SDK range and all snapshot files. Public
-verification compares every uploaded file, including transitive chunks and generated metadata,
-checks CORS, and confirms that Registry changes only the manifest's public path.
-
-Public executable responses use `Cache-Control: public, no-cache` and an ETag so browsers revalidate
-Registry readability when a Release is blocked. Hosted documentation is discovered independently
-for the exact Release; browser clients follow the returned absolute URL and never invent a hostname.
-
-`node --test scripts/test-native-extension-candidates.mjs` checks shared version generation, candidate
-capture, Python-only preservation, saved-candidate protection and provenance over an isolated fixture.
-This does not establish full Client Web enablement; that requires a real published preview Release
-and browser acceptance against the preview Registry.
diff --git a/extensions/AGENTS.md b/extensions/AGENTS.md
index 65189cfd..d03bdbd6 100644
--- a/extensions/AGENTS.md
+++ b/extensions/AGENTS.md
@@ -15,10 +15,10 @@ Host/producer runtime contracts belong to
`mf-manifest.json`, and write `dist/client-web`.
- Export host-consumed capabilities from `src/index.ts`; keep `src/main.ts` limited to the local
playground. Do not make the Host depend on playground bootstrap behavior.
-- Changes to a native producer must preserve the asset-closure validation. Record its Release intent
- in the corresponding core-py Extension release fragment; do not select it in Changesets.
-- The unified Core release plan generates native package versions in the isolated build checkout.
- Committed native package versions are development metadata, not a second Release authority.
+- Changes to a native producer must preserve asset-closure validation. Record its native release intent
+ in a Changeset for that package; its committed package version owns the MF release coordinate.
+- Build and publish only this repository’s MF distributions. Python producers use their own repositories
+ and versions; do not read their source or version to choose an MF release.
## Required Check
diff --git a/extensions/mail/CHANGELOG.md b/extensions/mail/CHANGELOG.md
index a9b47e93..5699da62 100644
--- a/extensions/mail/CHANGELOG.md
+++ b/extensions/mail/CHANGELOG.md
@@ -1,5 +1,19 @@
# @inkcre/ext-mail
+## 0.3.2
+
+### Patch Changes
+
+- 插件配置与业务状态在部署内共享;精确分发版本和启用意图分别保存在每个 Peer。Client Web 在同一插件入口管理各 Peer 的安装、版本和运行状态,并采用 Extension management v2。
+
+ 原生 SDK 不再提供全局插件版本与 enabled 数组。新版 MF 使用 0.4 SDK 的参与记录接口,历史 MF 需按其 SDK 范围运行或明确选择新版;不会自动改写旧版本选择。Python 与 MF 分别从自己的仓库发布,插件负责共享数据契约兼容。
+
+- 7307dda: Source 与 Peer 配置使用可视化表单,保留完整 JSON 编辑模式;Peers 页面可确认删除离线 Peer 并清理其扩展启用记录。Extension 浏览与版本选择交由 Registry,Web 接收精确版本并确认安装;Web 与 Module Federation 扩展统一升级正式 UI 2.2.0,Peer AI 凭据在 schema 中标记为密码输入。
+- Updated dependencies [7307dda]
+- Updated dependencies
+- Updated dependencies [7307dda]
+ - @inkcre/core@0.4.0
+
## 0.3.1
### Patch Changes
diff --git a/extensions/mail/package.json b/extensions/mail/package.json
index cb676552..997220f4 100644
--- a/extensions/mail/package.json
+++ b/extensions/mail/package.json
@@ -1,6 +1,6 @@
{
"name": "@inkcre/ext-mail",
- "version": "0.3.1",
+ "version": "0.3.2",
"private": true,
"type": "module",
"scripts": {
@@ -38,7 +38,7 @@
"nickname": "Mail",
"module_federation": {
"host_sdk": "@inkcre/core",
- "host_sdk_version": ">=0.2.0 <0.4.0"
+ "host_sdk_version": ">=0.4.0 <0.5.0"
}
},
"intent": {
diff --git a/extensions/memos/CHANGELOG.md b/extensions/memos/CHANGELOG.md
index ea1e0368..707e81e4 100644
--- a/extensions/memos/CHANGELOG.md
+++ b/extensions/memos/CHANGELOG.md
@@ -1,5 +1,19 @@
# @inkcre/ext-memos
+## 0.3.1
+
+### Patch Changes
+
+- 插件配置与业务状态在部署内共享;精确分发版本和启用意图分别保存在每个 Peer。Client Web 在同一插件入口管理各 Peer 的安装、版本和运行状态,并采用 Extension management v2。
+
+ 原生 SDK 不再提供全局插件版本与 enabled 数组。新版 MF 使用 0.4 SDK 的参与记录接口,历史 MF 需按其 SDK 范围运行或明确选择新版;不会自动改写旧版本选择。Python 与 MF 分别从自己的仓库发布,插件负责共享数据契约兼容。
+
+- 7307dda: Source 与 Peer 配置使用可视化表单,保留完整 JSON 编辑模式;Peers 页面可确认删除离线 Peer 并清理其扩展启用记录。Extension 浏览与版本选择交由 Registry,Web 接收精确版本并确认安装;Web 与 Module Federation 扩展统一升级正式 UI 2.2.0,Peer AI 凭据在 schema 中标记为密码输入。
+- Updated dependencies [7307dda]
+- Updated dependencies
+- Updated dependencies [7307dda]
+ - @inkcre/core@0.4.0
+
## 0.3.0
### Minor Changes
diff --git a/extensions/memos/package.json b/extensions/memos/package.json
index 8fa69ca5..cbbab212 100644
--- a/extensions/memos/package.json
+++ b/extensions/memos/package.json
@@ -1,6 +1,6 @@
{
"name": "@inkcre/ext-memos",
- "version": "0.3.0",
+ "version": "0.3.1",
"private": true,
"type": "module",
"scripts": {
@@ -11,7 +11,7 @@
},
"dependencies": {
"@inkcre/core": "workspace:*",
- "@inkcre/extension-runtime-client-web": "https://github.com/InKCre/ext-reg/releases/download/runtime-client-web-v0.2.0/inkcre-extension-runtime-client-web-0.2.0.tgz"
+ "@inkcre/extension-runtime-client-web": "github:InKCre/ext-reg#ff599f32e8d6e186974cf342a95fee306d9e60f8&path:/runtimes/client-web"
},
"devDependencies": {
"@inkcre/ext-dev-utils": "workspace:*",
@@ -35,7 +35,7 @@
"nickname": "Memos",
"module_federation": {
"host_sdk": "@inkcre/core",
- "host_sdk_version": ">=0.3.0 <0.4.0"
+ "host_sdk_version": ">=0.4.0 <0.5.0"
}
},
"intent": {
diff --git a/extensions/memos/src/MemosSetup.vue b/extensions/memos/src/MemosSetup.vue
index 8cbd97c2..67bacf4e 100644
--- a/extensions/memos/src/MemosSetup.vue
+++ b/extensions/memos/src/MemosSetup.vue
@@ -3,6 +3,9 @@ import { computed, inject, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import { InkButton, InkDropdown, InkForm, InkInput, INK_I18N_KEY } from '@inkcre/ui-web'
import {
ExtensionModel,
+ ExtensionParticipationModel,
+ configStore,
+ type ExtensionParticipation,
PeerOutcomeUnknown,
type InstalledExtension,
type Peer,
@@ -10,6 +13,7 @@ import {
import { manageExtensionOnPeer } from '@inkcre/extension-runtime-client-web'
import {
extensionName,
+ ensureMemosParticipation,
generateToken,
MemosConnectionError,
personalAccessToken,
@@ -42,7 +46,15 @@ onUnmounted(() => {
const peer = computed(() => peers.value.find((candidate) => candidate.id === selected.value))
const savedToken = computed(() => (extension.value ? personalAccessToken(extension.value) : null))
-const enabled = computed(() => extension.value?.enabled.includes(selected.value) ?? false)
+const participations = ref([])
+const enabled = computed(() =>
+ participations.value.some((row) => row.peer_id === selected.value && row.enabled)
+)
+const localVersion = computed(
+ () =>
+ participations.value.find((row) => row.peer_id === configStore.metaConfig.INKCRE_PEER_ID)
+ ?.version
+)
const ready = computed(() => loaded.value && address.value && savedToken.value && enabled.value)
const options = computed(() =>
peers.value.map((candidate) => ({
@@ -56,11 +68,11 @@ async function loadHelp(version: string) {
helpStatus.value = 'loading'
try {
const url = await readHelpUrl(version)
- if (!mounted || extension.value?.version !== version) return
+ if (!mounted || localVersion.value !== version) return
helpUrl.value = url
helpStatus.value = url ? 'available' : 'missing'
} catch {
- if (mounted && extension.value?.version === version) helpStatus.value = 'unavailable'
+ if (mounted && localVersion.value === version) helpStatus.value = 'unavailable'
}
}
@@ -94,7 +106,11 @@ async function refresh() {
const current = await readSetup()
if (!mounted) return
extension.value = current.extension
- void loadHelp(current.extension.version)
+ participations.value = (await ExtensionParticipationModel.list()).filter(
+ (row) => row.name === extensionName
+ )
+ if (localVersion.value) void loadHelp(localVersion.value)
+ else helpStatus.value = 'missing'
peers.value = current.peers
if (!selected.value && current.peers.length === 1) selected.value = current.peers[0]!.id
loaded.value = true
@@ -135,22 +151,27 @@ async function prepare() {
const current = await ExtensionModel.get(extensionName)
if (!current) throw new Error('Memos is no longer installed.')
extension.value = current
+ await ensureMemosParticipation(target)
if (!personalAccessToken(current)) {
phase = 'save'
draft.value ||= generateToken()
- extension.value = await manageExtensionOnPeer(target, {
+ await manageExtensionOnPeer(target, {
action: 'patch_config',
extension: extensionName,
patch: { personal_access_token: draft.value },
})
}
phase = 'enable'
- if (!extension.value.enabled.includes(target)) {
- extension.value = await manageExtensionOnPeer(target, {
+ if (!enabled.value) {
+ await manageExtensionOnPeer(target, {
action: 'enable',
extension: extensionName,
})
}
+ extension.value = await ExtensionModel.get(extensionName)
+ participations.value = (await ExtensionParticipationModel.list()).filter(
+ (row) => row.name === extensionName
+ )
draft.value = ''
await loadAddress()
} catch (cause) {
diff --git a/extensions/memos/src/setup.ts b/extensions/memos/src/setup.ts
index b3227ff1..3b07d5fa 100644
--- a/extensions/memos/src/setup.ts
+++ b/extensions/memos/src/setup.ts
@@ -1,5 +1,7 @@
+import corePackage from '../../../packages/core/package.json'
import {
ExtensionModel,
+ ExtensionParticipationModel,
configStore,
ExtensionRegistryOriginResolver,
PeerManager,
@@ -9,6 +11,10 @@ import {
} from '@inkcre/core'
import {
getExtensionDocumentation,
+ RegistryReleaseReader,
+ sortPublishedReleases,
+ manageExtensionOnPeer,
+ readExtensionPeerStatus,
listAdvertisedExtensionManagementPeers,
} from '@inkcre/extension-runtime-client-web'
@@ -31,7 +37,39 @@ export async function readSetup(): Promise<{ extension: ExtensionModel; peers: P
listAdvertisedExtensionManagementPeers(),
])
if (!extension) throw new Error('Memos is not installed.')
- return { extension, peers }
+ const statuses = await Promise.all(
+ peers.map(async (peer) => ({
+ peer,
+ status: await readExtensionPeerStatus(peer.id, extensionName),
+ }))
+ )
+ return {
+ extension,
+ peers: statuses
+ .filter(({ status }) => status.supported_distributions.includes('python'))
+ .map(({ peer }) => peer),
+ }
+}
+
+/** An explicit connection-preparation action may create a native selection, never background restore. */
+export async function ensureMemosParticipation(peerId: string): Promise {
+ if (await ExtensionParticipationModel.get(peerId, extensionName)) return
+ const origin = await new ExtensionRegistryOriginResolver(
+ () => configStore.peerConfig.extension_registry_url
+ ).resolve()
+ const reader = new RegistryReleaseReader({
+ registryOrigin: origin,
+ hostSdk: { name: '@inkcre/core', version: corePackage.version },
+ })
+ const catalog = await reader.getExtension(extensionName, 'python')
+ const release = sortPublishedReleases(catalog.releases ?? [])[0]
+ if (!release) throw new Error('Memos has no published Python distribution.')
+ await manageExtensionOnPeer(peerId, {
+ action: 'select',
+ extension: extensionName,
+ distribution: 'python',
+ version: release.version,
+ })
}
export class MemosConnectionError extends Error {
@@ -68,7 +106,12 @@ export async function readHelpUrl(version: string): Promise {
const origin = await new ExtensionRegistryOriginResolver(
() => configStore.peerConfig.extension_registry_url
).resolve()
- const documentation = await getExtensionDocumentation(origin, extensionName, version)
+ const documentation = await getExtensionDocumentation(
+ origin,
+ extensionName,
+ version,
+ 'module_federation'
+ )
const entry = documentation?.find((link) => link.scope === 'module-federation')
if (!entry) return null
const url = new URL(entry.entry_url)
diff --git a/extensions/twitter/CHANGELOG.md b/extensions/twitter/CHANGELOG.md
index b4538310..d1fdc7bc 100644
--- a/extensions/twitter/CHANGELOG.md
+++ b/extensions/twitter/CHANGELOG.md
@@ -1,5 +1,19 @@
# @inkcre/ext-twitter
+## 0.4.2
+
+### Patch Changes
+
+- 插件配置与业务状态在部署内共享;精确分发版本和启用意图分别保存在每个 Peer。Client Web 在同一插件入口管理各 Peer 的安装、版本和运行状态,并采用 Extension management v2。
+
+ 原生 SDK 不再提供全局插件版本与 enabled 数组。新版 MF 使用 0.4 SDK 的参与记录接口,历史 MF 需按其 SDK 范围运行或明确选择新版;不会自动改写旧版本选择。Python 与 MF 分别从自己的仓库发布,插件负责共享数据契约兼容。
+
+- 7307dda: Source 与 Peer 配置使用可视化表单,保留完整 JSON 编辑模式;Peers 页面可确认删除离线 Peer 并清理其扩展启用记录。Extension 浏览与版本选择交由 Registry,Web 接收精确版本并确认安装;Web 与 Module Federation 扩展统一升级正式 UI 2.2.0,Peer AI 凭据在 schema 中标记为密码输入。
+- Updated dependencies [7307dda]
+- Updated dependencies
+- Updated dependencies [7307dda]
+ - @inkcre/core@0.4.0
+
## 0.4.1
### Patch Changes
diff --git a/extensions/twitter/package.json b/extensions/twitter/package.json
index db78b3ca..bdefbc68 100644
--- a/extensions/twitter/package.json
+++ b/extensions/twitter/package.json
@@ -1,6 +1,6 @@
{
"name": "@inkcre/ext-twitter",
- "version": "0.4.1",
+ "version": "0.4.2",
"private": true,
"type": "module",
"scripts": {
@@ -10,7 +10,8 @@
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
- "@inkcre/core": "workspace:*"
+ "@inkcre/core": "workspace:*",
+ "@inkcre/extension-runtime-client-web": "github:InKCre/ext-reg#ff599f32e8d6e186974cf342a95fee306d9e60f8&path:/runtimes/client-web"
},
"devDependencies": {
"@inkcre/ext-dev-utils": "workspace:*",
@@ -36,7 +37,7 @@
"nickname": "Twitter",
"module_federation": {
"host_sdk": "@inkcre/core",
- "host_sdk_version": ">=0.2.0 <0.4.0"
+ "host_sdk_version": ">=0.4.0 <0.5.0"
}
},
"intent": {
diff --git a/extensions/twitter/src/components/twitterSetupWizard/twitterSetupWizard.vue b/extensions/twitter/src/components/twitterSetupWizard/twitterSetupWizard.vue
index 636a753e..afae069a 100644
--- a/extensions/twitter/src/components/twitterSetupWizard/twitterSetupWizard.vue
+++ b/extensions/twitter/src/components/twitterSetupWizard/twitterSetupWizard.vue
@@ -182,8 +182,7 @@ async function enableSelectedCore(): Promise {
if (!api.value || !selectedCandidate.value) return
await run('enable-core', async () => {
const extension = await api.value!.enableCore(lifecycle.signal)
- selectedCandidate.value!.extension = extension
- selectedCandidate.value!.enabled = extension.enabled.includes(selectedCandidate.value!.peer.id)
+ selectedCandidate.value!.enabled = extension.enabled
await reloadStatus()
})
}
diff --git a/extensions/twitter/src/setup-api.ts b/extensions/twitter/src/setup-api.ts
index 579c79a3..96b1b44d 100644
--- a/extensions/twitter/src/setup-api.ts
+++ b/extensions/twitter/src/setup-api.ts
@@ -1,5 +1,14 @@
+import corePackage from '../../../packages/core/package.json'
import {
- InstalledExtensionSchema,
+ RegistryReleaseReader,
+ sortPublishedReleases,
+ manageExtensionOnPeer,
+ readExtensionPeerStatus,
+} from '@inkcre/extension-runtime-client-web'
+import {
+ ExtensionParticipationModel,
+ ExtensionRegistryOriginResolver,
+ configStore,
Cron,
CronForm,
ExtensionModel,
@@ -10,12 +19,13 @@ import {
Source,
SourceForm,
type InstalledExtension,
+ type ExtensionRuntimeStatus,
type JsonValue,
} from '@inkcre/core'
import { z } from 'zod'
const EXTENSION_NAME = 'inkcre/twitter'
-const EXTENSION_MANAGEMENT_CAPABILITY = 'core.extension.management.v1'
+const EXTENSION_MANAGEMENT_CAPABILITY = 'core.extension.management.v2'
const TWITTER_SETUP_STATUS_CAPABILITY = 'inkcre.twitter.setup.status.v1'
const TWITTER_OAUTH_APP_CONFIGURE_CAPABILITY = 'inkcre.twitter.oauth-app.configure.v1'
const TWITTER_OAUTH_BEGIN_CAPABILITY = 'inkcre.twitter.oauth.begin.v1'
@@ -91,18 +101,30 @@ function advertises(peer: Peer, capability: string): boolean {
export async function discoverCoreCandidates(signal?: AbortSignal): Promise {
signal?.throwIfAborted()
- const [extension, peers] = await Promise.all([
+ const [extension, peers, participations] = await Promise.all([
ExtensionModel.get(EXTENSION_NAME),
PeerManager.listLive(),
+ ExtensionParticipationModel.list(),
])
signal?.throwIfAborted()
if (!extension) return []
- return peers
- .filter((peer) => advertises(peer, EXTENSION_MANAGEMENT_CAPABILITY))
- .map((peer) => ({
+ const candidates = await Promise.all(
+ peers
+ .filter((peer) => advertises(peer, EXTENSION_MANAGEMENT_CAPABILITY))
+ .map(async (peer) => ({
+ peer,
+ status: await readExtensionPeerStatus(peer.id, EXTENSION_NAME),
+ }))
+ )
+ signal?.throwIfAborted()
+ return candidates
+ .filter(({ status }) => status.supported_distributions.includes('python'))
+ .map(({ peer }) => ({
peer,
extension,
- enabled: extension.enabled.includes(peer.id),
+ enabled: participations.some(
+ (row) => row.name === EXTENSION_NAME && row.peer_id === peer.id && row.enabled
+ ),
setupAvailable: advertises(peer, TWITTER_SETUP_STATUS_CAPABILITY),
}))
}
@@ -110,14 +132,28 @@ export async function discoverCoreCandidates(signal?: AbortSignal): Promise {
- return delegateTwitterRequest(
- this.peer,
- EXTENSION_MANAGEMENT_CAPABILITY,
- { body: { action: 'enable', extension: EXTENSION_NAME } },
- InstalledExtensionSchema,
- signal
- )
+ async enableCore(signal?: AbortSignal): Promise {
+ const existing = await ExtensionParticipationModel.get(this.peer.id, EXTENSION_NAME)
+ if (!existing) {
+ const origin = await new ExtensionRegistryOriginResolver(
+ () => configStore.peerConfig.extension_registry_url
+ ).resolve()
+ const reader = new RegistryReleaseReader({
+ registryOrigin: origin,
+ hostSdk: { name: '@inkcre/core', version: corePackage.version },
+ })
+ const catalog = await reader.getExtension(EXTENSION_NAME, 'python')
+ const release = sortPublishedReleases(catalog.releases ?? [])[0]
+ if (!release) throw new Error('Twitter has no published Python distribution.')
+ await manageExtensionOnPeer(this.peer.id, {
+ action: 'select',
+ extension: EXTENSION_NAME,
+ distribution: 'python',
+ version: release.version,
+ })
+ }
+ signal?.throwIfAborted()
+ return manageExtensionOnPeer(this.peer.id, { action: 'enable', extension: EXTENSION_NAME })
}
status(signal?: AbortSignal): Promise {
diff --git a/packages/ext-dev-utils/CHANGELOG.md b/packages/ext-dev-utils/CHANGELOG.md
index c7b08b87..b0eaa34e 100644
--- a/packages/ext-dev-utils/CHANGELOG.md
+++ b/packages/ext-dev-utils/CHANGELOG.md
@@ -1,5 +1,15 @@
# @inkcre/ext-dev-utils
+## 0.1.3
+
+### Patch Changes
+
+- 7307dda: Source 与 Peer 配置使用可视化表单,保留完整 JSON 编辑模式;Peers 页面可确认删除离线 Peer 并清理其扩展启用记录。Extension 浏览与版本选择交由 Registry,Web 接收精确版本并确认安装;Web 与 Module Federation 扩展统一升级正式 UI 2.2.0,Peer AI 凭据在 schema 中标记为密码输入。
+- Updated dependencies [7307dda]
+- Updated dependencies
+- Updated dependencies [7307dda]
+ - @inkcre/core@0.4.0
+
## 0.1.2
### Patch Changes
diff --git a/packages/ext-dev-utils/package.json b/packages/ext-dev-utils/package.json
index 680244f1..0556d2d4 100644
--- a/packages/ext-dev-utils/package.json
+++ b/packages/ext-dev-utils/package.json
@@ -1,6 +1,6 @@
{
"name": "@inkcre/ext-dev-utils",
- "version": "0.1.2",
+ "version": "0.1.3",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 02b5b18a..0c9f214e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -96,8 +96,8 @@ importers:
specifier: workspace:*
version: link:../../packages/core
'@inkcre/extension-runtime-client-web':
- specifier: github:InKCre/ext-reg#57dcd80289285620a1fd27a0a48027e56c4ce7b1&path:/runtimes/client-web
- version: https://codeload.github.com/InKCre/ext-reg/tar.gz/57dcd80289285620a1fd27a0a48027e56c4ce7b1#path:/runtimes/client-web(@inkcre/core@packages+core)(@module-federation/runtime@0.21.6)
+ specifier: github:InKCre/ext-reg#ff599f32e8d6e186974cf342a95fee306d9e60f8&path:/runtimes/client-web
+ version: https://codeload.github.com/InKCre/ext-reg/tar.gz/ff599f32e8d6e186974cf342a95fee306d9e60f8#path:/runtimes/client-web(@inkcre/core@packages+core)(@module-federation/runtime@0.21.6)
'@inkcre/ui-web':
specifier: 2.2.1
version: 2.2.1(unocss@66.7.5(vite@7.3.6(@types/node@22.17.2)(jiti@2.7.0)(lightningcss@1.33.0)(sass-embedded@1.90.0)(sass@1.101.6)(yaml@2.9.0)))(vue@3.5.40(typescript@5.9.3))
@@ -354,8 +354,8 @@ importers:
specifier: workspace:*
version: link:../../packages/core
'@inkcre/extension-runtime-client-web':
- specifier: https://github.com/InKCre/ext-reg/releases/download/runtime-client-web-v0.2.0/inkcre-extension-runtime-client-web-0.2.0.tgz
- version: https://github.com/InKCre/ext-reg/releases/download/runtime-client-web-v0.2.0/inkcre-extension-runtime-client-web-0.2.0.tgz(@inkcre/core@packages+core)(@module-federation/runtime@0.22.1)
+ specifier: github:InKCre/ext-reg#ff599f32e8d6e186974cf342a95fee306d9e60f8&path:/runtimes/client-web
+ version: https://codeload.github.com/InKCre/ext-reg/tar.gz/ff599f32e8d6e186974cf342a95fee306d9e60f8#path:/runtimes/client-web(@inkcre/core@packages+core)(@module-federation/runtime@0.22.1)
'@vueuse/core':
specifier: ^14.0.0
version: 14.3.0(vue@3.5.40(typescript@5.9.3))
@@ -402,6 +402,9 @@ importers:
'@inkcre/core':
specifier: workspace:*
version: link:../../packages/core
+ '@inkcre/extension-runtime-client-web':
+ specifier: github:InKCre/ext-reg#ff599f32e8d6e186974cf342a95fee306d9e60f8&path:/runtimes/client-web
+ version: https://codeload.github.com/InKCre/ext-reg/tar.gz/ff599f32e8d6e186974cf342a95fee306d9e60f8#path:/runtimes/client-web(@inkcre/core@packages+core)(@module-federation/runtime@0.22.1)
'@vueuse/core':
specifier: ^14.0.0
version: 14.3.0(vue@3.5.40(typescript@5.9.3))
@@ -1399,18 +1402,11 @@ packages:
cpu: [x64]
os: [win32]
- '@inkcre/extension-runtime-client-web@https://codeload.github.com/InKCre/ext-reg/tar.gz/57dcd80289285620a1fd27a0a48027e56c4ce7b1#path:/runtimes/client-web':
- resolution: {gitHosted: true, integrity: sha512-WydOwa8HJ4/aGdk0vhlUV+CzmkWNeQ7KY6YV1F5NXEG07L1pf3Tm4rawOFdFZnLQBbu3AYpQ5IPlhpIfD5r/fg==, path: /runtimes/client-web, tarball: https://codeload.github.com/InKCre/ext-reg/tar.gz/57dcd80289285620a1fd27a0a48027e56c4ce7b1}
- version: 0.2.0
- peerDependencies:
- '@inkcre/core': '>=0.3.0 <0.4.0'
- '@module-federation/runtime': '>=0.21.4 <0.23.0'
-
- '@inkcre/extension-runtime-client-web@https://github.com/InKCre/ext-reg/releases/download/runtime-client-web-v0.2.0/inkcre-extension-runtime-client-web-0.2.0.tgz':
- resolution: {integrity: sha512-Rj39VKGY32qMAiYjoxZbmGR1dnEdQsNpVWjPBKor7beqE5WDpU9BAw7YMOCkouf6WxeX9jt8Ap70RbDkkKEyuQ==, tarball: https://github.com/InKCre/ext-reg/releases/download/runtime-client-web-v0.2.0/inkcre-extension-runtime-client-web-0.2.0.tgz}
- version: 0.2.0
+ '@inkcre/extension-runtime-client-web@https://codeload.github.com/InKCre/ext-reg/tar.gz/ff599f32e8d6e186974cf342a95fee306d9e60f8#path:/runtimes/client-web':
+ resolution: {gitHosted: true, integrity: sha512-bA/tiNmkOiUs75yZ2j+i1XXbhUuYN+CCeBEjl5zhv0Nkfm8O5PxPOBkGWzkW5NU2J65x/pxI6+Ef7ve1S9h/bw==, path: /runtimes/client-web, tarball: https://codeload.github.com/InKCre/ext-reg/tar.gz/ff599f32e8d6e186974cf342a95fee306d9e60f8}
+ version: 0.4.0
peerDependencies:
- '@inkcre/core': '>=0.3.0 <0.4.0'
+ '@inkcre/core': '>=0.4.0 <0.5.0'
'@module-federation/runtime': '>=0.21.4 <0.23.0'
'@inkcre/ui-web@2.2.1':
@@ -7422,14 +7418,14 @@ snapshots:
'@img/sharp-win32-x64@0.35.2':
optional: true
- '@inkcre/extension-runtime-client-web@https://codeload.github.com/InKCre/ext-reg/tar.gz/57dcd80289285620a1fd27a0a48027e56c4ce7b1#path:/runtimes/client-web(@inkcre/core@packages+core)(@module-federation/runtime@0.21.6)':
+ '@inkcre/extension-runtime-client-web@https://codeload.github.com/InKCre/ext-reg/tar.gz/ff599f32e8d6e186974cf342a95fee306d9e60f8#path:/runtimes/client-web(@inkcre/core@packages+core)(@module-federation/runtime@0.21.6)':
dependencies:
'@inkcre/core': link:packages/core
'@module-federation/runtime': 0.21.6
semver: 7.8.5
zod: 4.1.12
- '@inkcre/extension-runtime-client-web@https://github.com/InKCre/ext-reg/releases/download/runtime-client-web-v0.2.0/inkcre-extension-runtime-client-web-0.2.0.tgz(@inkcre/core@packages+core)(@module-federation/runtime@0.22.1)':
+ '@inkcre/extension-runtime-client-web@https://codeload.github.com/InKCre/ext-reg/tar.gz/ff599f32e8d6e186974cf342a95fee306d9e60f8#path:/runtimes/client-web(@inkcre/core@packages+core)(@module-federation/runtime@0.22.1)':
dependencies:
'@inkcre/core': link:packages/core
'@module-federation/runtime': 0.22.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 6b990557..019613a2 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -8,7 +8,8 @@ catalog:
vue-tsc: 3.3.8
allowBuilds:
- '@inkcre/extension-runtime-client-web@https://codeload.github.com/InKCre/ext-reg/tar.gz/57dcd80289285620a1fd27a0a48027e56c4ce7b1#path:/runtimes/client-web': true
+ '@inkcre/extension-runtime-client-web@https://codeload.github.com/InKCre/ext-reg/tar.gz/57dcd80289285620a1fd27a0a48027e56c4ce7b1#path:/runtimes/client-web': false
+ '@inkcre/extension-runtime-client-web@https://codeload.github.com/InKCre/ext-reg/tar.gz/ff599f32e8d6e186974cf342a95fee306d9e60f8#path:/runtimes/client-web': true
'@parcel/watcher': false
esbuild: false
sharp: false
diff --git a/scripts/build-native-extension-candidates.mjs b/scripts/build-native-extension-candidates.mjs
deleted file mode 100644
index 1082d1f7..00000000
--- a/scripts/build-native-extension-candidates.mjs
+++ /dev/null
@@ -1,122 +0,0 @@
-import assert from 'node:assert/strict'
-import { execFileSync } from 'node:child_process'
-import { createHash } from 'node:crypto'
-import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
-import path from 'node:path'
-import { fileURLToPath } from 'node:url'
-import { valid as validSemVer } from 'semver'
-import {
- inspectNativeModuleFederation,
- listSnapshotFiles,
- prepareBody,
-} from './verify-native-extension-distribution.mjs'
-
-const readJson = async (file) => JSON.parse(await readFile(file, 'utf8'))
-const writeJson = (file, value) => writeFile(file, `${JSON.stringify(value, null, 2)}\n`)
-
-export async function consumePlan({ workspace, planPath, outputDirectory, run = execFileSync }) {
- const plan = await readJson(planPath)
- assert.ok(Array.isArray(plan.candidates), 'plan candidates must be an array')
- assert.match(plan.web_revision, /^[a-f0-9]{40}$/, 'fixed Web source revision is required')
- assert.equal(typeof plan.build_id, 'string', 'build ID is required')
- assert.equal(
- run('git', ['rev-parse', 'HEAD'], { cwd: workspace, encoding: 'utf8' }).trim(),
- plan.web_revision,
- 'Web checkout differs from the fixed release plan'
- )
- const producers = new Map()
- for (const entry of await readdir(path.join(workspace, 'extensions'), { withFileTypes: true })) {
- if (!entry.isDirectory()) continue
- const packagePath = path.join(workspace, 'extensions', entry.name, 'package.json')
- const manifest = await readJson(packagePath)
- assert.ok(manifest.inkcre?.module_federation, `${entry.name}: native MF metadata is required`)
- assert.ok(!producers.has(manifest.inkcre.name), 'duplicate native producer identity')
- producers.set(manifest.inkcre.name, { directory: entry.name, packagePath, manifest })
- }
- const selected = []
- const directories = new Set()
- for (const candidate of plan.candidates) {
- assert.match(candidate.directory, /^[a-zA-Z0-9_-]+$/, 'unsafe candidate directory')
- assert.ok(!directories.has(candidate.directory), 'duplicate candidate directory')
- directories.add(candidate.directory)
- assert.equal(validSemVer(candidate.version), candidate.version, 'invalid Release version')
- assert.equal(candidate.prepare?.version, candidate.version, 'prepare Release version differs')
- const producer = producers.get(candidate.name)
- const directoryProducer = [...producers.values()].find(
- (value) => value.directory === candidate.directory
- )
- assert.ok(
- !directoryProducer || directoryProducer === producer,
- `${candidate.name}: Web producer directory declares a different identity`
- )
- if (producer) selected.push({ candidate, producer })
- }
- // Generate all selected versions before any build can read another workspace package.
- for (const { candidate, producer } of selected) {
- producer.manifest.version = candidate.version
- await writeJson(producer.packagePath, producer.manifest)
- }
- if (selected.length)
- run('pnpm', ['--filter', '@inkcre/core', 'build'], { cwd: workspace, stdio: 'inherit' })
- await mkdir(outputDirectory, { recursive: true })
- for (const { candidate, producer } of selected) {
- run('pnpm', ['--filter', `./extensions/${producer.directory}`, 'build'], {
- cwd: workspace,
- stdio: 'inherit',
- })
- const artifactDirectory = path.join(path.dirname(producer.packagePath), 'dist/client-web')
- const local = await inspectNativeModuleFederation({
- packagePath: producer.packagePath,
- corePackagePath: path.join(workspace, 'packages/core/package.json'),
- artifactDirectory,
- })
- assert.equal(local.name, candidate.name)
- assert.equal(local.version, candidate.version)
- await listSnapshotFiles(artifactDirectory)
- const destination = path.join(outputDirectory, candidate.directory)
- await mkdir(destination, { recursive: true })
- const archive = path.join(destination, 'snapshot.zip')
- // Never replace saved bytes on a rerun; publication restores the whole Core-owned candidate.
- await writeFile(path.join(destination, 'candidate.lock'), '', { flag: 'wx' })
- run('zip', ['-q', '-r', archive, '.'], { cwd: artifactDirectory })
- const prepare = prepareBody(local, {
- source_repository: 'https://github.com/InKCre/client-web',
- source_revision: plan.web_revision,
- build_id: plan.build_id,
- })
- candidate.prepare.module_federation = prepare.module_federation
- candidate.module_federation = {
- file: `${candidate.directory}/snapshot.zip`,
- sha256: createHash('sha256')
- .update(await readFile(archive))
- .digest('hex'),
- }
- }
- await writeJson(path.join(outputDirectory, 'plan.json'), plan)
- return plan
-}
-
-async function main() {
- assert.equal(process.argv[2], 'consume-plan', 'expected consume-plan')
- const options = new Map()
- for (let i = 3; i < process.argv.length; i += 2) {
- assert.ok(
- process.argv[i]?.startsWith('--') && process.argv[i + 1],
- 'expected option/value pair'
- )
- options.set(process.argv[i], process.argv[i + 1])
- }
- assert.ok(options.get('--plan') && options.get('--output'), '--plan and --output are required')
- await consumePlan({
- workspace: process.cwd(),
- planPath: path.resolve(options.get('--plan')),
- outputDirectory: path.resolve(options.get('--output')),
- })
-}
-
-if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
- main().catch((error) => {
- console.error(`[ERROR] ${error.message}`)
- process.exitCode = 1
- })
-}
diff --git a/scripts/core-release-lib.mjs b/scripts/core-release-lib.mjs
index bc8dd1e5..1e2ad08f 100644
--- a/scripts/core-release-lib.mjs
+++ b/scripts/core-release-lib.mjs
@@ -23,6 +23,27 @@ export function validateCoreReleaseConfig(config) {
) {
throw new Error('core release config must select InKCre/core-py stable')
}
+ if (config.canonical_candidate !== undefined) {
+ const candidate = config.canonical_candidate
+ if (
+ !candidate ||
+ !digestPattern.test(candidate.image) ||
+ !sourceRevisionPattern.test(candidate.source_revision) ||
+ typeof candidate.contract_revision !== 'string' ||
+ !candidate.contract_revision
+ ) {
+ throw new Error(
+ 'canonical Core candidate requires a published repository digest, exact source SHA and database contract revision'
+ )
+ }
+ if (
+ Object.keys(candidate).some(
+ (key) => !['image', 'source_revision', 'contract_revision'].includes(key)
+ )
+ ) {
+ throw new Error('canonical Core candidate contains unsupported fields')
+ }
+ }
for (const name of ['pgvector', 'postgrest']) {
const image = config.runtime_images?.[name]
if (typeof image !== 'string' || !image.includes('@sha256:')) {
@@ -143,6 +164,24 @@ export function localDocker(args, options = {}) {
}).trim()
}
+/** A reviewed canonical candidate can advance consumer admission without moving stable. */
+export function resolveConfiguredCoreRelease(runDocker, config) {
+ validateCoreReleaseConfig(config)
+ const candidate = config.canonical_candidate
+ if (!candidate) return resolveCoreRelease(runDocker, config.stable_image)
+ runDocker(['pull', candidate.image], { timeout: 300_000 })
+ const release = inspectCoreRelease(runDocker, candidate.image)
+ if (
+ release.manifest.source_revision !== candidate.source_revision ||
+ release.manifest.contract_revision !== candidate.contract_revision
+ ) {
+ throw new Error(
+ 'canonical Core candidate source or database contract does not match its admission'
+ )
+ }
+ return release
+}
+
export function resolveCoreRelease(runDocker, stableImage) {
if (!stableImagePattern.test(stableImage)) {
throw new Error(`unexpected core stable image: ${stableImage}`)
diff --git a/scripts/database-runtime-lib.mjs b/scripts/database-runtime-lib.mjs
index 1861d61d..854afd48 100644
--- a/scripts/database-runtime-lib.mjs
+++ b/scripts/database-runtime-lib.mjs
@@ -9,7 +9,7 @@ import { SignJWT } from 'jose'
import {
inspectCoreRelease,
readJson,
- resolveCoreRelease,
+ resolveConfiguredCoreRelease,
stableJson,
validateCoreReleaseConfig,
} from './core-release-lib.mjs'
@@ -229,7 +229,7 @@ export async function runtimeState(instance, { create = false } = {}) {
const runDocker = (args, options) => runDatabaseDocker(provider, args, options)
const release = process.env.INKCRE_CORE_IMAGE
? inspectCoreRelease(runDocker, process.env.INKCRE_CORE_IMAGE)
- : resolveCoreRelease(runDocker, releaseConfig.stable_image)
+ : resolveConfiguredCoreRelease(runDocker, releaseConfig)
const localPorts = {
postgres: await availablePort(),
core: await availablePort(),
diff --git a/scripts/publish-first-party-extensions.mjs b/scripts/publish-first-party-extensions.mjs
new file mode 100644
index 00000000..86be1912
--- /dev/null
+++ b/scripts/publish-first-party-extensions.mjs
@@ -0,0 +1,361 @@
+import assert from 'node:assert/strict'
+import { execFileSync } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import { appendFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { setTimeout } from 'node:timers/promises'
+import { fileURLToPath } from 'node:url'
+import {
+ inspectNativeModuleFederation,
+ listSnapshotFiles,
+ prepareBody,
+ verifyPublicModuleFederation,
+} from './verify-native-extension-distribution.mjs'
+
+const ARTIFACT_NAME = 'first-party-extension-candidate'
+const OBJECTS_PENDING = 'Release native Distribution objects are not completely available'
+const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
+const readJson = async (file) => JSON.parse(await readFile(file, 'utf8'))
+const writeJson = (file, data) => writeFile(file, `${JSON.stringify(data, null, 2)}\n`)
+
+export function selectSavedCandidate(artifacts, runAttempt) {
+ assert.match(runAttempt, /^[1-9]\d*$/, 'current GitHub run attempt is required')
+ const candidates = artifacts.filter((artifact) => artifact.name === ARTIFACT_NAME)
+ assert.ok(candidates.length <= 1, 'workflow run has multiple release candidates')
+ const candidate = candidates[0]
+ assert.ok(!candidate?.expired, 'saved candidate expired; do not rebuild a prepared Release')
+ // GitHub deletes artifacts on rerun-all. Only partial reruns can retain the original candidate.
+ // ponytail: also reject pre-capture retries; operator review is safer than inferring prior writes.
+ assert.ok(
+ candidate || runAttempt === '1',
+ 'Saved candidate is missing on a rerun. Stop: never rebuild a possibly prepared Release. Use the original artifact and rerun only publication; see first-party delivery recovery guidance.'
+ )
+ return candidate
+}
+
+class RegistryError extends Error {
+ constructor(stage, status, detail) {
+ super(`${stage}: Registry HTTP ${status || 'unavailable'}: ${detail}`)
+ this.status = status
+ this.detail = detail
+ this.uncertain = [0, 408, 500, 502, 503, 504].includes(status)
+ }
+}
+
+async function request(url, options, fetchImplementation) {
+ let response
+ let body
+ try {
+ response = await fetchImplementation(url, {
+ ...options,
+ redirect: 'error',
+ signal: AbortSignal.timeout(45_000),
+ })
+ body = await response.text()
+ } catch (error) {
+ throw new RegistryError(`${options.method ?? 'GET'} ${url.pathname}`, 0, error.message)
+ }
+ let result
+ try {
+ result = JSON.parse(body)
+ } catch {
+ result = { detail: body.slice(0, 500) }
+ }
+ if (!response.ok) {
+ throw new RegistryError(
+ `${options.method ?? 'GET'} ${url.pathname}`,
+ response.status,
+ result.detail ?? body.slice(0, 500)
+ )
+ }
+ return result
+}
+
+function releaseUrl(registryUrl, name, version) {
+ return new URL(
+ `/v2/extensions/${name}/distributions/module_federation/releases/${version}`,
+ registryUrl
+ )
+}
+
+async function publicRelease(url, fetchImplementation) {
+ try {
+ const release = await request(url, {}, fetchImplementation)
+ assert.equal(
+ release.distribution,
+ 'module_federation',
+ 'Registry returned a different native distribution'
+ )
+ assert.equal(release.state, 'published', `cannot deliver a ${release.state} Release`)
+ return release
+ } catch (error) {
+ if (error.status === 404) return null
+ throw error
+ }
+}
+
+export async function captureCandidates({
+ workspace,
+ deliveryDirectory,
+ registryUrl,
+ provenance,
+ fetchImplementation = fetch,
+}) {
+ await mkdir(deliveryDirectory, { recursive: true })
+ const candidates = []
+ for (const directory of await readdir(path.join(workspace, 'extensions'), {
+ withFileTypes: true,
+ })) {
+ if (!directory.isDirectory()) continue
+ const packagePath = path.join(workspace, 'extensions', directory.name, 'package.json')
+ let manifest
+ try {
+ manifest = await readJson(packagePath)
+ } catch (error) {
+ if (error.code === 'ENOENT') continue
+ throw error
+ }
+ if (!manifest.inkcre?.module_federation) continue
+ const existing = await publicRelease(
+ releaseUrl(registryUrl, manifest.inkcre.name, manifest.version),
+ fetchImplementation
+ )
+ if (existing?.module_federation) {
+ console.log(
+ `${manifest.inkcre.name} ${manifest.version}: historical Release, no publication selected.`
+ )
+ continue
+ }
+ const artifactDirectory = path.join(path.dirname(packagePath), 'dist/client-web')
+ const local = await inspectNativeModuleFederation({
+ packagePath,
+ corePackagePath: path.join(workspace, 'packages/core/package.json'),
+ artifactDirectory,
+ })
+ await listSnapshotFiles(artifactDirectory)
+ const candidateDirectory = path.join(deliveryDirectory, directory.name)
+ // A candidate is written once. Re-runs restore the saved artifact before entering this command.
+ await mkdir(candidateDirectory)
+ const prepare = prepareBody(local, provenance)
+ await writeJson(path.join(candidateDirectory, 'prepare.json'), prepare)
+ const archive = path.join(candidateDirectory, 'snapshot.zip')
+ execFileSync('zip', ['-q', '-r', archive, '.'], { cwd: artifactDirectory })
+ candidates.push({
+ directory: directory.name,
+ name: local.name,
+ version: local.version,
+ sha256: sha256(await readFile(archive)),
+ })
+ }
+ const plan = { ...provenance, registry_url: registryUrl, candidates }
+ await writeJson(path.join(deliveryDirectory, 'plan.json'), plan)
+ return plan
+}
+
+export async function checkCandidates({ workspace, deliveryDirectory, registryUrl, provenance }) {
+ const plan = await readJson(path.join(deliveryDirectory, 'plan.json'))
+ assert.deepEqual(
+ {
+ source_repository: plan.source_repository,
+ source_revision: plan.source_revision,
+ build_id: plan.build_id,
+ registry_url: plan.registry_url,
+ },
+ { ...provenance, registry_url: registryUrl },
+ 'saved candidate must belong to this source revision, workflow run and Registry'
+ )
+ for (const candidate of plan.candidates) {
+ assert.match(candidate.directory, /^[a-zA-Z0-9_-]+$/)
+ const candidateDirectory = path.join(deliveryDirectory, candidate.directory)
+ const archive = await readFile(path.join(candidateDirectory, 'snapshot.zip'))
+ assert.equal(sha256(archive), candidate.sha256, `${candidate.name}: saved ZIP digest differs`)
+ const artifactDirectory = await mkdtemp(path.join(os.tmpdir(), 'inkcre-release-candidate-'))
+ try {
+ execFileSync('unzip', [
+ '-q',
+ path.join(candidateDirectory, 'snapshot.zip'),
+ '-d',
+ artifactDirectory,
+ ])
+ const local = await inspectNativeModuleFederation({
+ packagePath: path.join(workspace, 'extensions', candidate.directory, 'package.json'),
+ corePackagePath: path.join(workspace, 'packages/core/package.json'),
+ artifactDirectory,
+ })
+ await listSnapshotFiles(artifactDirectory)
+ assert.equal(candidate.name, local.name)
+ assert.equal(candidate.version, local.version)
+ assert.deepEqual(
+ await readJson(path.join(candidateDirectory, 'prepare.json')),
+ prepareBody(local, provenance)
+ )
+ } finally {
+ await rm(artifactDirectory, { recursive: true })
+ }
+ }
+ return plan
+}
+
+async function finishPublication(url, token, fetchImplementation) {
+ // A new MF-only Release stays invisible while preparing. Retry publish, not just public GET.
+ for (let attempt = 0; attempt < 7; attempt++) {
+ if (attempt) await setTimeout(5_000)
+ try {
+ const result = await request(
+ new URL(`${url.pathname}/publish`, url),
+ {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}` },
+ },
+ fetchImplementation
+ )
+ if (result.state === 'published' && result.module_federation) return
+ } catch (error) {
+ if (!error.uncertain && !(error.status === 409 && error.detail === OBJECTS_PENDING))
+ throw error
+ console.warn(error.message)
+ }
+ try {
+ const visible = await publicRelease(url, fetchImplementation)
+ if (visible?.module_federation) return
+ } catch (error) {
+ if (!error.uncertain) throw error
+ console.warn(error.message)
+ }
+ }
+ throw new Error(
+ 'Publication is not confirmed. Re-run the failed job with its saved candidate; do not rebuild or replace the Release.'
+ )
+}
+
+export async function publishCandidates(options) {
+ const { workspace, deliveryDirectory, registryUrl, token, fetchImplementation = fetch } = options
+ assert.ok(token, 'publisher token is required')
+ const plan = await checkCandidates(options)
+ for (const candidate of plan.candidates) {
+ const candidateDirectory = path.join(deliveryDirectory, candidate.directory)
+ const url = releaseUrl(registryUrl, candidate.name, candidate.version)
+ const prepare = await readFile(path.join(candidateDirectory, 'prepare.json'))
+ // prepare is idempotent and also checks immutable provenance before a resumed publication.
+ await request(
+ new URL(
+ `/v2/extensions/${candidate.name}/distributions/module_federation/releases`,
+ registryUrl
+ ),
+ {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
+ body: prepare,
+ },
+ fetchImplementation
+ )
+ const existing = await publicRelease(url, fetchImplementation)
+ if (!existing?.module_federation) {
+ const form = new FormData()
+ form.append(
+ 'content',
+ new Blob([await readFile(path.join(candidateDirectory, 'snapshot.zip'))], {
+ type: 'application/zip',
+ }),
+ 'snapshot.zip'
+ )
+ try {
+ await request(
+ new URL(`${url.pathname}/module-federation`, url),
+ {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}` },
+ body: form,
+ },
+ fetchImplementation
+ )
+ } catch (error) {
+ if (!error.uncertain) throw error
+ console.warn(`${error.message}; upload outcome is unknown, checking completion.`)
+ }
+ await finishPublication(url, token, fetchImplementation)
+ }
+ const artifactDirectory = await mkdtemp(path.join(os.tmpdir(), 'inkcre-release-verification-'))
+ try {
+ execFileSync('unzip', [
+ '-q',
+ path.join(candidateDirectory, 'snapshot.zip'),
+ '-d',
+ artifactDirectory,
+ ])
+ const result = await verifyPublicModuleFederation({
+ registryUrl,
+ packagePath: path.join(workspace, 'extensions', candidate.directory, 'package.json'),
+ corePackagePath: path.join(workspace, 'packages/core/package.json'),
+ artifactDirectory,
+ fetchImplementation,
+ })
+ const summary = `${candidate.name} ${candidate.version}: published and verified (${result.verified_assets} files), ZIP SHA256 ${candidate.sha256}.\n`
+ console.log(summary.trim())
+ if (process.env.GITHUB_STEP_SUMMARY)
+ await appendFile(process.env.GITHUB_STEP_SUMMARY, summary)
+ } finally {
+ await rm(artifactDirectory, { recursive: true })
+ }
+ }
+}
+
+async function main() {
+ const command = process.argv[2]
+ if (command === 'locate') {
+ const {
+ GITHUB_REPOSITORY: repository,
+ GITHUB_RUN_ID: runId,
+ GITHUB_RUN_ATTEMPT: runAttempt,
+ GITHUB_OUTPUT: output,
+ } = process.env
+ assert.ok(repository && runId && output, 'locate requires the current GitHub workflow run')
+ const pages = JSON.parse(
+ execFileSync(
+ 'gh',
+ ['api', `repos/${repository}/actions/runs/${runId}/artifacts`, '--paginate', '--slurp'],
+ { encoding: 'utf8' }
+ )
+ )
+ const candidate = selectSavedCandidate(
+ pages.flatMap((page) => page.artifacts),
+ runAttempt
+ )
+ await appendFile(output, `artifact_id=${candidate?.id ?? ''}\n`)
+ return
+ }
+ const {
+ SOURCE_REPOSITORY: repository,
+ SOURCE_REVISION: revision,
+ GITHUB_RUN_ID: runId,
+ INKCRE_EXTENSION_REGISTRY_URL: registryUrl,
+ } = process.env
+ assert.ok(
+ repository && revision && runId && registryUrl,
+ 'source, workflow run and Registry are required'
+ )
+ assert.equal(new URL(registryUrl).protocol, 'https:', 'Registry must use HTTPS')
+ const options = {
+ workspace: process.cwd(),
+ deliveryDirectory: path.resolve('.extension-delivery'),
+ registryUrl,
+ provenance: {
+ source_repository: repository,
+ source_revision: revision,
+ build_id: `client-web-extension-release-${runId}`,
+ },
+ }
+ if (command === 'capture') await captureCandidates(options)
+ else if (command === 'check') await checkCandidates(options)
+ else if (command === 'publish')
+ await publishCandidates({ ...options, token: process.env.INKCRE_EXTENSION_REGISTRY_TOKEN })
+ else throw new Error(`unsupported command: ${command}`)
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ main().catch((error) => {
+ console.error(`[ERROR] ${error.message}`)
+ process.exitCode = 1
+ })
+}
diff --git a/scripts/resolve-core-release.mjs b/scripts/resolve-core-release.mjs
index e587a9b0..f7ebb94b 100644
--- a/scripts/resolve-core-release.mjs
+++ b/scripts/resolve-core-release.mjs
@@ -1,14 +1,18 @@
import { appendFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
-import { readJson, resolveCoreRelease, validateCoreReleaseConfig } from './core-release-lib.mjs'
+import {
+ readJson,
+ resolveConfiguredCoreRelease,
+ validateCoreReleaseConfig,
+} from './core-release-lib.mjs'
import { resolveDatabaseProviderConfig, runDatabaseDocker } from './database-provider.mjs'
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
const config = validateCoreReleaseConfig(await readJson(`${repoRoot}/contracts/core-release.json`))
const provider = resolveDatabaseProviderConfig()
const runDocker = (args, options) => runDatabaseDocker(provider, args, options)
-const release = resolveCoreRelease(runDocker, config.stable_image)
+const release = resolveConfiguredCoreRelease(runDocker, config)
if (process.env.GITHUB_OUTPUT) {
await appendFile(
diff --git a/scripts/sync-database-contract.mjs b/scripts/sync-database-contract.mjs
index acafb17e..193bc4af 100644
--- a/scripts/sync-database-contract.mjs
+++ b/scripts/sync-database-contract.mjs
@@ -7,7 +7,7 @@ import {
inspectCoreRelease,
projectDatabaseCompatibilityContract,
readJson,
- resolveCoreRelease,
+ resolveConfiguredCoreRelease,
stableJson,
validateCoreReleaseConfig,
} from './core-release-lib.mjs'
@@ -29,7 +29,7 @@ const provider = resolveDatabaseProviderConfig()
const runDocker = (dockerArgs, options) => runDatabaseDocker(provider, dockerArgs, options)
const release = requestedImage
? inspectCoreRelease(runDocker, requestedImage)
- : resolveCoreRelease(runDocker, config.stable_image)
+ : resolveConfiguredCoreRelease(runDocker, config)
const instance = `typegen-${createHash('sha256')
.update(`${repoRoot}/${process.pid}/${Date.now()}`)
.digest('hex')
diff --git a/scripts/test-core-release.mjs b/scripts/test-core-release.mjs
new file mode 100644
index 00000000..eb473665
--- /dev/null
+++ b/scripts/test-core-release.mjs
@@ -0,0 +1,61 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { resolveConfiguredCoreRelease, validateCoreReleaseConfig } from './core-release-lib.mjs'
+
+const source = 'a'.repeat(40)
+const image = `ghcr.io/inkcre/core-py@sha256:${'b'.repeat(64)}`
+const revision = 'extension-peer-participation-v2'
+const config = {
+ format: 1,
+ repository: 'InKCre/core-py',
+ stable_image: 'ghcr.io/inkcre/core-py:stable',
+ runtime_images: { pgvector: 'pgvector@sha256:1', postgrest: 'postgrest@sha256:2' },
+ canonical_candidate: { image, source_revision: source, contract_revision: revision },
+}
+const artifact = (path) => ({ path, sha256: 'c'.repeat(64), size: 1 })
+function dockerFor(sourceRevision = source, contractRevision = revision) {
+ return (args) => {
+ if (args[0] === 'pull') return ''
+ if (args.includes('schema'))
+ return JSON.stringify({
+ format: 1,
+ source_revision: sourceRevision,
+ contract_revision: contractRevision,
+ schema: artifact('/app/database-contract/database-schema.sql'),
+ roles: artifact('/app/database-contract/database-roles.sql'),
+ runtime_contract: artifact('/app/database-contract/runtime-contract.json'),
+ })
+ return JSON.stringify({
+ format: 1,
+ revision: contractRevision,
+ source_revision: sourceRevision,
+ protocol: { schema: 'inkcre' },
+ jwt: { algorithm: 'HS256', role: 'authenticated' },
+ })
+ }
+}
+
+test('consumer admission rejects unknown candidate origin, source and contract', () => {
+ assert.equal(resolveConfiguredCoreRelease(dockerFor(), config).image, image)
+ assert.throws(
+ () => resolveConfiguredCoreRelease(dockerFor('d'.repeat(40)), config),
+ /source or database contract/
+ )
+ assert.throws(
+ () => resolveConfiguredCoreRelease(dockerFor(source, 'old-contract'), config),
+ /source or database contract/
+ )
+ for (const invalid of [
+ 'ghcr.io/inkcre/core-py:preview',
+ image.replace('ghcr.io', 'other.example'),
+ ]) {
+ assert.throws(
+ () =>
+ validateCoreReleaseConfig({
+ ...config,
+ canonical_candidate: { ...config.canonical_candidate, image: invalid },
+ }),
+ /requires a published repository digest/
+ )
+ }
+})
diff --git a/scripts/test-extension-publication.mjs b/scripts/test-extension-publication.mjs
new file mode 100644
index 00000000..b8ecf930
--- /dev/null
+++ b/scripts/test-extension-publication.mjs
@@ -0,0 +1,210 @@
+import assert from 'node:assert/strict'
+import { execFileSync } from 'node:child_process'
+import { once } from 'node:events'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { createServer } from 'node:http'
+import os from 'node:os'
+import path from 'node:path'
+import { test } from 'node:test'
+import {
+ captureCandidates,
+ checkCandidates,
+ publishCandidates,
+ selectSavedCandidate,
+} from './publish-first-party-extensions.mjs'
+
+test('a rerun cannot replace a missing or expired release candidate', () => {
+ const candidate = { id: 42, name: 'first-party-extension-candidate', expired: false }
+ const unrelated = { id: 43, name: 'other-artifact', expired: false }
+ assert.equal(selectSavedCandidate([unrelated], '1'), undefined)
+ assert.equal(selectSavedCandidate([unrelated, candidate], '2'), candidate)
+ assert.throws(() => selectSavedCandidate([unrelated], '2'), /missing on a rerun/)
+ assert.throws(() => selectSavedCandidate([{ ...candidate, expired: true }], '2'), /expired/)
+ assert.throws(() => selectSavedCandidate([candidate, candidate], '2'), /multiple/)
+ assert.throws(() => selectSavedCandidate([], undefined), /current GitHub run attempt/)
+})
+
+// A real HTTP fault-injection boundary for the publisher, not a replacement Registry implementation.
+test('an unknown MF-only upload resumes the saved ZIP and verifies even an existing association', async () => {
+ const workspace = await mkdtemp(path.join(os.tmpdir(), 'inkcre-publication-test-'))
+ const registryUrl = 'https://registry.example.test'
+ const prefix = '/extensions/test/source/1.0.0/module-federation/'
+ const build = path.join(workspace, 'extensions/source/dist/client-web')
+ const uploaded = path.join(workspace, 'uploaded')
+ let prepared
+ let published = false
+ let uploads = 0
+ let publishes = 0
+ let writes = 0
+ let corrupt = false
+ let forceConflict = false
+ const server = createServer(async (req, res) => {
+ try {
+ res.setHeader('Access-Control-Allow-Origin', '*')
+ const json = (status, body) => {
+ res.writeHead(status, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify(body))
+ }
+ const record = () => ({
+ name: 'test/source',
+ nickname: 'Source',
+ version: '1.0.0',
+ state: 'published',
+ distribution: 'module_federation',
+ module_federation: {
+ host_sdk: '@inkcre/core',
+ host_sdk_version: '>=0.2.0 <0.4.0',
+ manifest_url: `${prefix}mf-manifest.json`,
+ },
+ })
+ if (req.method === 'POST') {
+ writes++
+ assert.equal(req.headers.authorization, 'Bearer test-only')
+ const chunks = []
+ for await (const chunk of req) chunks.push(chunk)
+ const body = Buffer.concat(chunks)
+ if (req.url.endsWith('/releases')) {
+ if (forceConflict)
+ return json(409, { detail: 'Module Federation association is immutable' })
+ const value = JSON.parse(body)
+ if (prepared) assert.deepEqual(value, prepared)
+ prepared = value
+ return json(200, { state: 'preparing' })
+ }
+ if (req.url.endsWith('/module-federation')) {
+ uploads++
+ const form = await new Response(body, {
+ headers: { 'Content-Type': req.headers['content-type'] },
+ }).formData()
+ const zip = path.join(workspace, 'received.zip')
+ await writeFile(zip, Buffer.from(await form.get('content').arrayBuffer()))
+ execFileSync('unzip', ['-q', zip, '-d', uploaded])
+ // Heroku can return H12 while the server keeps processing this exact archive.
+ return json(503, { detail: 'H12 request timeout' })
+ }
+ if (req.url.endsWith('/publish')) {
+ publishes++
+ if (publishes === 1)
+ return json(409, {
+ detail: 'Release native Distribution objects are not completely available',
+ })
+ published = true
+ return json(200, record())
+ }
+ }
+ if (req.url.startsWith('/v2/'))
+ return json(published ? 200 : 404, published ? record() : { detail: 'not found' })
+ if (!published) return json(404, { detail: 'not found' })
+ const relativePath = req.url.slice(prefix.length)
+ let bytes = await readFile(path.join(uploaded, relativePath))
+ if (relativePath === 'mf-manifest.json') {
+ const manifest = JSON.parse(bytes)
+ manifest.metaData.publicPath = `${registryUrl}${prefix}`
+ bytes = Buffer.from(JSON.stringify(manifest))
+ }
+ if (corrupt && relativePath === 'transitive.js') bytes = Buffer.from('changed')
+ res.writeHead(200, { 'Cache-Control': 'public, no-cache', ETag: '"snapshot"' })
+ res.end(bytes)
+ } catch (error) {
+ res.writeHead(500)
+ res.end(error.message)
+ }
+ })
+ try {
+ await mkdir(path.join(build, '.vite'), { recursive: true })
+ await mkdir(path.join(workspace, 'extensions/.agents'))
+ await mkdir(path.join(workspace, 'extensions/guidance'))
+ await mkdir(path.join(workspace, 'packages/core'), { recursive: true })
+ await writeFile(
+ path.join(workspace, 'packages/core/package.json'),
+ JSON.stringify({ version: '0.3.0' })
+ )
+ await writeFile(
+ path.join(workspace, 'extensions/source/package.json'),
+ JSON.stringify({
+ version: '1.0.0',
+ inkcre: {
+ name: 'test/source',
+ nickname: 'Source',
+ module_federation: { host_sdk: '@inkcre/core', host_sdk_version: '>=0.2.0 <0.4.0' },
+ },
+ })
+ )
+ await writeFile(
+ path.join(build, 'mf-manifest.json'),
+ JSON.stringify({
+ name: 'source',
+ metaData: { publicPath: './', remoteEntry: { name: 'remoteEntry.js' } },
+ shared: [{ name: '@inkcre/core', requiredVersion: '>=0.2.0 <0.4.0' }],
+ exposes: [],
+ })
+ )
+ await writeFile(path.join(build, 'remoteEntry.js'), 'import "./transitive.js"')
+ await writeFile(path.join(build, 'transitive.js'), 'export default 42')
+ await writeFile(path.join(build, '.vite/manifest.json'), '{}')
+ server.listen(0, '127.0.0.1')
+ await once(server, 'listening')
+ const origin = `http://127.0.0.1:${server.address().port}`
+ const options = {
+ workspace,
+ registryUrl,
+ deliveryDirectory: path.join(workspace, 'candidate'),
+ token: 'test-only',
+ provenance: {
+ source_repository: 'https://github.com/InKCre/client-web',
+ source_revision: 'exact-source',
+ build_id: 'same-run',
+ },
+ fetchImplementation: (url, init) => fetch(new URL(new URL(url).pathname, origin), init),
+ }
+ await captureCandidates(options)
+ assert.equal(writes, 0, 'capturing a candidate must not mutate the Registry')
+ // A rebuild could produce other bytes; publication must still use the retained ZIP.
+ await writeFile(path.join(build, 'transitive.js'), 'export default "different build"')
+ await publishCandidates(options)
+ assert.equal(uploads, 1)
+ assert.equal(
+ publishes,
+ 2,
+ 'MF-only preparation must progress through publish, not public GET alone'
+ )
+ await publishCandidates(options)
+ assert.equal(uploads, 1, 'a ready candidate is verified without uploading it again')
+ corrupt = true
+ await assert.rejects(publishCandidates(options), /public asset bytes differ: transitive.js/)
+ corrupt = false
+ forceConflict = true
+ const beforeConflict = writes
+ await assert.rejects(publishCandidates(options), /association is immutable/)
+ assert.equal(writes, beforeConflict + 1, 'immutable conflicts must not retry')
+ forceConflict = false
+ const beforeInvalid = writes
+ await assert.rejects(
+ checkCandidates({ ...options, provenance: { ...options.provenance, build_id: 'new-run' } }),
+ /saved candidate must belong/
+ )
+ await writeFile(path.join(options.deliveryDirectory, 'source/snapshot.zip'), 'corrupt')
+ await assert.rejects(publishCandidates(options), /saved ZIP digest differs/)
+ assert.equal(writes, beforeInvalid, 'invalid saved candidates must fail before Registry writes')
+ await rm(build, { recursive: true })
+ const historical = await captureCandidates({
+ ...options,
+ deliveryDirectory: path.join(workspace, 'later-run'),
+ })
+ assert.deepEqual(
+ historical.candidates,
+ [],
+ 'unrelated builds must not claim to verify historical releases'
+ )
+ await writeFile(path.join(workspace, 'extensions/guidance/package.json'), '{invalid')
+ await assert.rejects(
+ captureCandidates({ ...options, deliveryDirectory: path.join(workspace, 'invalid-run') }),
+ SyntaxError,
+ 'only absent package metadata may be skipped, not invalid package metadata'
+ )
+ } finally {
+ server.closeAllConnections()
+ await new Promise((resolve) => server.close(resolve))
+ await rm(workspace, { recursive: true })
+ }
+})
diff --git a/scripts/test-native-extension-candidates.mjs b/scripts/test-native-extension-candidates.mjs
deleted file mode 100644
index a8aaf182..00000000
--- a/scripts/test-native-extension-candidates.mjs
+++ /dev/null
@@ -1,72 +0,0 @@
-import assert from 'node:assert/strict'
-import { execFileSync } from 'node:child_process'
-import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
-import os from 'node:os'
-import path from 'node:path'
-import { test } from 'node:test'
-import { consumePlan } from './build-native-extension-candidates.mjs'
-
-const writeJson = (file, data) => writeFile(file, JSON.stringify(data))
-
-test('one plan generates MF version and provenance while preserving Python-only candidates', async () => {
- const workspace = await mkdtemp(path.join(os.tmpdir(), 'inkcre-assembly-'))
- try {
- await mkdir(path.join(workspace, 'extensions/mail/dist/client-web'), { recursive: true })
- await mkdir(path.join(workspace, 'packages/core'), { recursive: true })
- await writeJson(path.join(workspace, 'packages/core/package.json'), { version: '0.3.0' })
- const packagePath = path.join(workspace, 'extensions/mail/package.json')
- await writeJson(packagePath, {
- version: '0.1.0',
- inkcre: {
- name: 'inkcre/mail',
- nickname: 'Mail',
- module_federation: { host_sdk: '@inkcre/core', host_sdk_version: '>=0.2.0 <0.4.0' },
- },
- })
- const dist = path.join(workspace, 'extensions/mail/dist/client-web')
- await writeFile(path.join(dist, 'remoteEntry.js'), 'export default {}')
- await writeJson(path.join(dist, 'mf-manifest.json'), {
- metaData: { publicPath: './', remoteEntry: { name: 'remoteEntry.js' } },
- shared: [{ name: '@inkcre/core', requiredVersion: '>=0.2.0 <0.4.0' }],
- exposes: [],
- })
- const plan = {
- web_revision: 'a'.repeat(40),
- build_id: 'core-release-123',
- candidates: ['mail', 'github'].map((directory) => ({
- directory,
- name: `inkcre/${directory}`,
- version: '0.3.99',
- prepare: { version: '0.3.99', python: { package: directory } },
- })),
- }
- const planPath = path.join(workspace, 'input.json')
- await writeJson(planPath, plan)
- const options = {
- workspace,
- planPath,
- outputDirectory: path.join(workspace, 'output'),
- run(command, args, settings) {
- if (command === 'git') return plan.web_revision
- if (command === 'zip') execFileSync(command, args, settings)
- },
- }
- const result = await consumePlan(options)
- assert.equal(JSON.parse(await readFile(packagePath)).version, '0.3.99')
- assert.deepEqual(result.candidates[1], plan.candidates[1])
- assert.equal(result.candidates[0].prepare.python.package, 'mail')
- assert.equal(result.candidates[0].prepare.module_federation.source_revision, plan.web_revision)
- assert.match(result.candidates[0].module_federation.sha256, /^[a-f0-9]{64}$/)
- assert.equal(result.candidates[0].module_federation.file, 'mail/snapshot.zip')
- await assert.rejects(consumePlan(options), { code: 'EEXIST' })
- plan.candidates[0].name = 'inkcre/typo'
- await writeJson(planPath, plan)
- await assert.rejects(consumePlan(options), /different identity/)
- plan.candidates[0].name = 'inkcre/mail'
- plan.candidates[0].directory = '../escape'
- await writeJson(planPath, plan)
- await assert.rejects(consumePlan(options), /unsafe candidate directory/)
- } finally {
- await rm(workspace, { recursive: true, force: true })
- }
-})
diff --git a/scripts/verify-native-extension-distribution.mjs b/scripts/verify-native-extension-distribution.mjs
index 8261bc1b..1688e028 100644
--- a/scripts/verify-native-extension-distribution.mjs
+++ b/scripts/verify-native-extension-distribution.mjs
@@ -175,7 +175,7 @@ export async function verifyPublicModuleFederation({
assert.equal(registry.protocol, 'https:', 'Registry URL must use HTTPS')
const [namespace, name] = local.name.split('/')
const releaseUrl = new URL(
- `/v1/extensions/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/releases/${encodeURIComponent(local.version)}`,
+ `/v2/extensions/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}/distributions/module_federation/releases/${encodeURIComponent(local.version)}`,
registry
)
const releaseResponse = await fetchImplementation(releaseUrl, {
@@ -194,17 +194,19 @@ export async function verifyPublicModuleFederation({
nickname: release.nickname,
version: release.version,
state: release.state,
- module_federation: release.module_federation,
+ distribution: release.distribution,
+ module_federation: {
+ host_sdk: release.module_federation?.host_sdk,
+ host_sdk_version: release.module_federation?.host_sdk_version,
+ },
},
{
name: local.name,
nickname: local.nickname,
version: local.version,
state: 'published',
- module_federation: {
- manifest_url: `/extensions/${local.name}/${local.version}/module-federation/mf-manifest.json`,
- ...local.module_federation,
- },
+ distribution: 'module_federation',
+ module_federation: local.module_federation,
},
'public exact Release differs from checked association'
)
@@ -322,6 +324,7 @@ export function previewRelease(local) {
nickname: local.nickname,
version: local.version,
state: 'published',
+ distribution: 'module_federation',
module_federation: {
manifest_url: `/extensions/${local.name}/${local.version}/module-federation/mf-manifest.json`,
...local.module_federation,
diff --git a/scripts/verify-pages-deployment.mjs b/scripts/verify-pages-deployment.mjs
index fdb0d57b..a76ad6f9 100644
--- a/scripts/verify-pages-deployment.mjs
+++ b/scripts/verify-pages-deployment.mjs
@@ -86,7 +86,7 @@ if (previewProducer) {
throw new Error('preview producer is missing inkcre.name or version')
}
const [namespace, name] = extensionName.split('/')
- const releasePath = `/v1/extensions/${namespace}/${name}/releases/${extensionVersion}`
+ const releasePath = `/v2/extensions/${namespace}/${name}/distributions/module_federation/releases/${extensionVersion}`
const release = await readPreviewResponse(releasePath).then((response) => response.json())
if (release.name !== extensionName || release.version !== extensionVersion) {
throw new Error('preview exact Release differs from its producer metadata')
diff --git a/tests/e2e/peer-database.spec.ts b/tests/e2e/peer-database.spec.ts
index 1014933f..75682b6b 100644
--- a/tests/e2e/peer-database.spec.ts
+++ b/tests/e2e/peer-database.spec.ts
@@ -245,30 +245,33 @@ test('a pending initial lease does not block pages or admit the browser worker',
let extensionReads = 0
let enabledReleaseReads = 0
let leasePending = false
- await page.route('**/extensions?**', async (route) => {
+ await page.route('**/extension_peer_participations?**', async (route) => {
extensionReads++
await route.fulfill({
headers: { 'Access-Control-Allow-Origin': '*' },
json: [
{
name: 'e2e/lease-order',
+ peer_id: '00000000-0000-4000-8000-000000000001',
+ distribution: 'module_federation',
version: '1.0.0',
- enabled: ['00000000-0000-4000-8000-000000000001'],
- nickname: null,
- config: {},
+ enabled: true,
config_schema: null,
},
],
})
})
- await page.route('**/v1/extensions/e2e/lease-order/releases/1.0.0', async (route) => {
- enabledReleaseReads++
- await route.fulfill({
- status: 404,
- headers: { 'Access-Control-Allow-Origin': '*' },
- json: { detail: 'isolated missing release' },
- })
- })
+ await page.route(
+ '**/v2/extensions/e2e/lease-order/distributions/module_federation/releases/1.0.0',
+ async (route) => {
+ enabledReleaseReads++
+ await route.fulfill({
+ status: 404,
+ headers: { 'Access-Control-Allow-Origin': '*' },
+ json: { detail: 'isolated missing release' },
+ })
+ }
+ )
await page.route('**/jobs?**', async (route) => {
workerRequests++
await route.continue()
@@ -790,47 +793,70 @@ test('disabling participation drains a dispatched claim before saving configurat
}
})
-test('Registry install and multi-Peer enablement require explicit confirmation', async ({
+test('Registry confirmation selects one Peer and offline enablement preserves other native pins', async ({
page,
}) => {
const extensionName = 'e2e/handoff'
const endpoint = `${postgrestUrl}extensions?name=eq.${encodeURIComponent(extensionName)}`
+ const selections = `${postgrestUrl}extension_peer_participations?name=eq.${encodeURIComponent(extensionName)}`
const authorization = `Bearer ${await token()}`
+ const headers = { Authorization: authorization, 'Content-Type': 'application/json' }
const peerIds = [crypto.randomUUID(), crypto.randomUUID()]
- await page.route('**/v1/extensions/e2e/handoff/releases/1.0.0', (route) =>
- route.fulfill({
- headers: { 'Access-Control-Allow-Origin': '*' },
- json: {
- name: extensionName,
- nickname: 'Handoff check',
- version: '1.0.0',
- state: 'published',
- },
- })
+ await page.route(
+ '**/v2/extensions/e2e/handoff/distributions/module_federation/releases/1.0.0',
+ (route) =>
+ route.fulfill({
+ headers: { 'Access-Control-Allow-Origin': '*' },
+ json: {
+ name: extensionName,
+ nickname: 'Handoff check',
+ version: '1.0.0',
+ distribution: 'module_federation',
+ state: 'published',
+ python: null,
+ module_federation: {
+ host_sdk: '@inkcre/core',
+ host_sdk_version: '>=0.2.0 <1.0.0',
+ manifest_url: '/extensions/e2e/handoff/1.0.0/module-federation/mf-manifest.json',
+ },
+ },
+ })
)
-
+ const readPins = async () => {
+ const response = await fetch(
+ `${selections}&select=peer_id,distribution,version,enabled&order=peer_id`,
+ { headers }
+ )
+ return response.ok ? await response.json() : null
+ }
try {
- await page.goto('/extensions?install=e2e%2Fhandoff&version=1.0.0')
+ await page.goto(
+ '/extensions?install=e2e%2Fhandoff&distribution=module_federation&version=1.0.0'
+ )
await expect(page.getByRole('heading', { name: 'Handoff check' })).toBeVisible()
- const before = await fetch(`${endpoint}&select=name`, {
- headers: { Authorization: authorization },
- })
- expect(before.ok ? await before.json() : null).toEqual([])
-
+ expect(await readPins()).toEqual([])
await page.getByRole('button', { name: 'Install', exact: true }).click()
await expect(page).toHaveURL(/\/extensions$/)
- await expect
- .poll(async () => {
- const response = await fetch(`${endpoint}&select=name,version,enabled`, {
- headers: { Authorization: authorization },
- })
- return response.ok ? await response.json() : null
- })
- .toEqual([{ name: extensionName, version: '1.0.0', enabled: [] }])
-
+ await expect.poll(readPins).toEqual([
+ {
+ peer_id: '00000000-0000-4000-8000-000000000001',
+ distribution: 'module_federation',
+ version: '1.0.0',
+ enabled: false,
+ },
+ ])
+ const seeded = await fetch(endpoint, {
+ method: 'PATCH',
+ headers,
+ body: JSON.stringify({
+ config: { mailbox: 'preserved' },
+ state: { account: { id: 'saved-account' }, cursor: 'saved-cursor' },
+ }),
+ })
+ expect(seeded.status).toBe(204)
const createdPeers = await fetch(`${postgrestUrl}peers`, {
method: 'POST',
- headers: { Authorization: authorization, 'Content-Type': 'application/json' },
+ headers,
body: JSON.stringify(
peerIds.map((id, index) => ({
id,
@@ -844,78 +870,64 @@ test('Registry install and multi-Peer enablement require explicit confirmation',
),
})
expect(createdPeers.status).toBe(201)
- await page.reload()
-
- await page.getByRole('button', { name: 'Enable…' }).click()
- const dialog = page.getByRole('dialog')
- await dialog.getByRole('checkbox', { name: /E2E Peer 1/ }).check()
- await dialog.getByRole('button', { name: 'Cancel' }).click()
- const readEnabled = async () => {
- const response = await fetch(`${endpoint}&select=enabled`, {
- headers: { Authorization: authorization },
- })
- return response.ok ? (await response.json())[0]?.enabled : null
- }
- expect(await readEnabled()).toEqual([])
-
- await page.getByRole('button', { name: 'Enable…' }).click()
- await dialog.getByRole('checkbox', { name: /E2E Peer 1/ }).check()
- await dialog.getByRole('checkbox', { name: /E2E Peer 2/ }).check()
- await dialog.getByRole('button', { name: 'Enable selected' }).click()
- await expect(dialog).toBeHidden()
- await expect.poll(readEnabled).toEqual(expect.arrayContaining(peerIds))
-
- await page.goto('/peers')
- await expect(
- page.locator('.peer-card', { hasText: 'This browser' }).getByRole('button', {
- name: 'Delete Peer',
- })
- ).toHaveCount(0)
- const peerToDelete = page.locator('.peer-card', { hasText: peerIds[0] })
- await peerToDelete.getByRole('button', { name: 'Delete Peer' }).click()
- const deleteDialog = page.getByRole('dialog', { name: 'Delete Peer' })
- await deleteDialog.getByRole('button', { name: 'Cancel' }).click()
- await expect(peerToDelete).toBeVisible()
- await expect.poll(readEnabled).toEqual(expect.arrayContaining(peerIds))
- await peerToDelete.getByRole('button', { name: 'Delete Peer' }).click()
- const deleteEndpoint = `${postgrestUrl}peers?id=eq.${peerIds[0]}`
- let releaseDelete!: () => void
- const pendingDelete = new Promise((resolve) => {
- releaseDelete = resolve
- })
- await page.route(deleteEndpoint, async (route) => {
- if (route.request().method() === 'DELETE') await pendingDelete
- await route.continue()
+ const pins = await fetch(`${postgrestUrl}extension_peer_participations`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(
+ peerIds.map((peer_id, index) => ({
+ name: extensionName,
+ peer_id,
+ distribution: 'python',
+ version: index ? '2.0.0' : '1.0.0',
+ enabled: false,
+ config_schema: null,
+ }))
+ ),
})
- await deleteDialog.getByRole('button', { name: 'Delete Peer' }).click()
- try {
- await expect(deleteDialog.getByRole('button', { name: 'Delete Peer' })).toHaveAttribute(
- 'aria-busy',
- 'true'
+ expect(pins.status).toBe(201)
+ await page.reload()
+ const first = page.getByRole('region', { name: 'E2E Peer 1' })
+ // Each row is its explicit target; one mutation cannot move the other Peer’s pin.
+ await first.getByRole('button', { name: 'Enable', exact: true }).click()
+ await expect
+ .poll(
+ async () =>
+ (await readPins())?.find((row: { peer_id: string }) => row.peer_id === peerIds[0])
+ ?.enabled
)
- const cancel = deleteDialog.getByRole('button', { name: 'Cancel' })
- await expect(cancel).toBeDisabled()
- await expect(cancel).not.toHaveAttribute('aria-busy', 'true')
- await expect(cancel.locator('.ink-button__loading')).toHaveCount(0)
- } finally {
- releaseDelete()
- }
- await expect(peerToDelete).toBeHidden()
- await page.unroute(deleteEndpoint)
- await expect.poll(readEnabled).toEqual([peerIds[1]])
-
- await page.goto('/extensions')
- await page.getByRole('button', { name: 'Disable…' }).click()
- await dialog.getByRole('checkbox', { name: /E2E Peer 2/ }).check()
- await dialog.getByRole('button', { name: 'Disable selected' }).click()
- await expect(dialog).toBeHidden()
- await expect.poll(readEnabled).toEqual([])
+ .toBe(true)
+ expect(
+ (await readPins())?.find((row: { peer_id: string }) => row.peer_id === peerIds[1])
+ ).toMatchObject({ distribution: 'python', version: '2.0.0', enabled: false })
+ await first.getByRole('button', { name: 'Disable', exact: true }).click()
+ await expect
+ .poll(
+ async () =>
+ (await readPins())?.find((row: { peer_id: string }) => row.peer_id === peerIds[0])
+ ?.enabled
+ )
+ .toBe(false)
+ await first.getByRole('button', { name: 'Remove', exact: true }).click()
+ await expect
+ .poll(async () =>
+ (await readPins())?.some((row: { peer_id: string }) => row.peer_id === peerIds[0])
+ )
+ .toBe(false)
+ const shared = await fetch(`${endpoint}&select=name,config,state`, { headers })
+ expect(await shared.json()).toEqual([
+ {
+ name: extensionName,
+ config: { mailbox: 'preserved' },
+ state: { account: { id: 'saved-account' }, cursor: 'saved-cursor' },
+ },
+ ])
+ expect(
+ (await readPins())?.find((row: { peer_id: string }) => row.peer_id === peerIds[1])
+ ).toMatchObject({ version: '2.0.0', enabled: false })
} finally {
- await fetch(endpoint, { method: 'DELETE', headers: { Authorization: authorization } })
- await fetch(`${postgrestUrl}peers?id=in.(${peerIds.join(',')})`, {
- method: 'DELETE',
- headers: { Authorization: authorization },
- })
+ await fetch(selections, { method: 'DELETE', headers })
+ await fetch(endpoint, { method: 'DELETE', headers })
+ await fetch(`${postgrestUrl}peers?id=in.(${peerIds.join(',')})`, { method: 'DELETE', headers })
}
})
From 6f751d85852870e79d5c1506acdc944df6642a4b Mon Sep 17 00:00:00 2001
From: Lan_zhijiang
Date: Tue, 13 Oct 2026 19:50:29 +0800
Subject: [PATCH 06/27] =?UTF-8?q?docs(shared):=20=E4=BF=9D=E7=95=99?=
=?UTF-8?q?=E5=85=B1=E4=BA=AB=E4=B8=9A=E5=8A=A1=E7=8A=B6=E6=80=81=E4=B8=8E?=
=?UTF-8?q?=E6=97=A7=E9=85=8D=E7=BD=AE=E6=9D=A5=E6=BA=90=E8=BE=B9=E7=95=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/_shared | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/_shared b/docs/_shared
index 11b465f4..2457c3b7 160000
--- a/docs/_shared
+++ b/docs/_shared
@@ -1 +1 @@
-Subproject commit 11b465f4d0be95075a99955181a20c797374019e
+Subproject commit 2457c3b7eae080285da1238daa1bb80caca55bb7
From f0f8b11db422e2fe0aa5cc3bc5ced0e6337ffe0f Mon Sep 17 00:00:00 2001
From: Lan_zhijiang
Date: Tue, 13 Oct 2026 19:55:13 +0800
Subject: [PATCH 07/27] =?UTF-8?q?build(preview):=20=E5=9B=BA=E5=AE=9A=20To?=
=?UTF-8?q?olkit=20v2=20=E5=8C=85=E5=B9=B6=E9=AA=8C=E6=94=B6=E5=8E=9F?=
=?UTF-8?q?=E7=94=9F=E5=88=86=E5=8F=91=E9=9D=99=E6=80=81=E7=9B=AE=E5=BD=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../native-extension-delivery.md | 8 ++
pdm.lock | 115 ++++++++----------
pyproject.toml | 4 +-
3 files changed, 60 insertions(+), 67 deletions(-)
diff --git a/docs/40-deployment/native-extension-delivery.md b/docs/40-deployment/native-extension-delivery.md
index b512c553..7ec5b846 100644
--- a/docs/40-deployment/native-extension-delivery.md
+++ b/docs/40-deployment/native-extension-delivery.md
@@ -63,6 +63,14 @@ Version outputs are committed; no duplicate pending intent remains for the next
This is the one-time delivery boundary for this cutover. Ordinary feature work continues to submit
Changesets and use the automated Version PR described above.
+The cutover qualifies Runtime SDK 0.4 and Toolkit 0.5 with exact package-source pins while their
+canonical artifacts are not yet published. The Runtime preparation fetches only the pinned
+`packages/core` SDK and uses its own frozen tooling; it does not install a producer workspace or
+combine Python and MF source. Real SDK tarballs and Toolkit wheels are also checked in isolation.
+This bounded source-package transition is not a claim that the SDK artifact delivery layer is
+fully independent. Candidate qualification, canonical dependency publication and production
+Extension publication remain separate observable delivery boundaries.
+
Required database checks normally resolve the protected Core `stable` artifact. During this cutover,
`contracts/core-release.json` can admit a `canonical_candidate` with an existing canonical Core
image digest, its protected-main source SHA and exact database contract revision. The consumer
diff --git a/pdm.lock b/pdm.lock
index a13054f4..b03eb6ac 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -5,7 +5,7 @@
groups = ["default"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
-content_hash = "sha256:bb96fd8f2ae26ebe087a8994dd781ca818e851daa167633222c620945f45a29a"
+content_hash = "sha256:f22216a3377a58eb14c1889196dd8031f1bbd4295772a6aad7b41fcfd265ec81"
[[metadata.targets]]
requires_python = "==3.13.*"
@@ -34,18 +34,18 @@ files = [
[[package]]
name = "anyio"
-version = "4.14.2"
+version = "4.15.1"
requires_python = ">=3.10"
summary = "High-level concurrency and networking framework on top of asyncio or Trio"
groups = ["default"]
dependencies = [
"exceptiongroup>=1.0.2; python_version < \"3.11\"",
"idna>=2.8",
- "typing-extensions>=4.5; python_version < \"3.13\"",
+ "typing-extensions>=4.16.0; python_version < \"3.15\"",
]
files = [
- {file = "anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494"},
- {file = "anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f"},
+ {file = "anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101"},
+ {file = "anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94"},
]
[[package]]
@@ -61,28 +61,13 @@ files = [
[[package]]
name = "click"
-version = "8.4.2"
+version = "8.5.0"
requires_python = ">=3.10"
summary = "Composable command line interface toolkit"
groups = ["default"]
-dependencies = [
- "colorama; platform_system == \"Windows\"",
-]
-files = [
- {file = "click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"},
- {file = "click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6"},
-]
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
-summary = "Cross-platform colored terminal text."
-groups = ["default"]
-marker = "platform_system == \"Windows\""
files = [
- {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
- {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+ {file = "click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360"},
+ {file = "click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34"},
]
[[package]]
@@ -130,20 +115,23 @@ files = [
[[package]]
name = "idna"
-version = "3.18"
+version = "3.20"
requires_python = ">=3.9"
summary = "Internationalized Domain Names in Applications (IDNA)"
groups = ["default"]
files = [
- {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
- {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
+ {file = "idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c"},
+ {file = "idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44"},
]
[[package]]
name = "inkcre-extension-toolkit"
-version = "0.2.1"
-requires_python = "<3.14,>=3.12"
-url = "https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.2.1/inkcre_extension_toolkit-0.2.1-py3-none-any.whl"
+version = "0.5.0"
+requires_python = ">=3.12,<3.14"
+subdirectory = "toolkit"
+git = "https://github.com/InKCre/ext-reg.git"
+ref = "f43f464c91bed93c70c9ab3a746f4a78377ac939"
+revision = "f43f464c91bed93c70c9ab3a746f4a78377ac939"
summary = "Developer and delivery tooling for InKCre Extensions"
groups = ["default"]
dependencies = [
@@ -151,27 +139,24 @@ dependencies = [
"pydantic<3,>=2.10.6",
"semantic-version<3,>=2.10",
]
-files = [
- {file = "inkcre_extension_toolkit-0.2.1-py3-none-any.whl", hash = "sha256:fffaad86db43d998731bfee69e992fa02d18ea2b428fdd00bda5ed6e311bf3de"},
-]
[[package]]
name = "inkcre-extension-toolkit"
-version = "0.2.1"
+version = "0.5.0"
extras = ["cli"]
-requires_python = "<3.14,>=3.12"
-url = "https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.2.1/inkcre_extension_toolkit-0.2.1-py3-none-any.whl"
+requires_python = ">=3.12,<3.14"
+subdirectory = "toolkit"
+git = "https://github.com/InKCre/ext-reg.git"
+ref = "f43f464c91bed93c70c9ab3a746f4a78377ac939"
+revision = "f43f464c91bed93c70c9ab3a746f4a78377ac939"
summary = "Developer and delivery tooling for InKCre Extensions"
groups = ["default"]
dependencies = [
"httpx<0.29,>=0.28",
- "inkcre-extension-toolkit @ https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.2.1/inkcre_extension_toolkit-0.2.1-py3-none-any.whl",
+ "inkcre-extension-toolkit @ git+https://github.com/InKCre/ext-reg.git@f43f464c91bed93c70c9ab3a746f4a78377ac939#subdirectory=toolkit",
"typer<0.22,>=0.21",
"wheel<0.48,>=0.46",
]
-files = [
- {file = "inkcre_extension_toolkit-0.2.1-py3-none-any.whl", hash = "sha256:fffaad86db43d998731bfee69e992fa02d18ea2b428fdd00bda5ed6e311bf3de"},
-]
[[package]]
name = "markdown-it-py"
@@ -211,47 +196,47 @@ files = [
[[package]]
name = "pydantic"
-version = "2.13.4"
-requires_python = ">=3.9"
+version = "2.14.1"
+requires_python = ">=3.10"
summary = "Data validation using Python type hints"
groups = ["default"]
dependencies = [
"annotated-types>=0.6.0",
- "pydantic-core==2.46.4",
- "typing-extensions>=4.14.1",
- "typing-inspection>=0.4.2",
+ "pydantic-core==2.50.1",
+ "typing-extensions>=4.16.0",
+ "typing-inspection>=0.4.4",
]
files = [
- {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"},
- {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"},
+ {file = "pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454"},
+ {file = "pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26"},
]
[[package]]
name = "pydantic-core"
-version = "2.46.4"
-requires_python = ">=3.9"
+version = "2.50.1"
+requires_python = ">=3.10"
summary = "Core functionality for Pydantic validation and serialization"
groups = ["default"]
dependencies = [
- "typing-extensions>=4.14.1",
+ "typing-extensions>=4.16.0",
]
files = [
- {file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"},
- {file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"},
- {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"},
- {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"},
- {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"},
- {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"},
- {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"},
- {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"},
- {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"},
- {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"},
- {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"},
- {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"},
- {file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"},
- {file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"},
- {file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"},
- {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f"},
+ {file = "pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662"},
+ {file = "pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09"},
]
[[package]]
diff --git a/pyproject.toml b/pyproject.toml
index d6a6b93e..2f347738 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,9 +3,9 @@ name = "inkcre-client-web-tooling"
version = "0.0.0"
description = "Repository-level development tooling for the InKCre Web monorepo"
requires-python = ">=3.13,<3.14"
-# Gate: generate and commit pdm.lock only after this immutable Toolkit wheel is published.
+# The exact Toolkit package source is admitted for this protocol cutover; canonical wheels remain the normal delivery boundary.
dependencies = [
- "inkcre-extension-toolkit[cli] @ https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.2.1/inkcre_extension_toolkit-0.2.1-py3-none-any.whl",
+ "inkcre-extension-toolkit[cli] @ git+https://github.com/InKCre/ext-reg.git@f43f464c91bed93c70c9ab3a746f4a78377ac939#subdirectory=toolkit",
]
[tool.pdm]
From 1b87edc086922fa59fceb5762145835b94442192 Mon Sep 17 00:00:00 2001
From: Lan_zhijiang
Date: Tue, 13 Oct 2026 20:00:05 +0800
Subject: [PATCH 08/27] =?UTF-8?q?fix(extensions):=20=E5=AE=89=E8=A3=85?=
=?UTF-8?q?=E6=97=B6=E4=B8=BA=E7=9B=AE=E6=A0=87=20Peer=20=E9=A2=84?=
=?UTF-8?q?=E9=80=89=E5=8F=AF=E7=94=A8=E5=8E=9F=E7=94=9F=E7=89=88=E6=9C=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/components/extension/extensionCard/extensionCard.vue | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/client-web/src/components/extension/extensionCard/extensionCard.vue b/apps/client-web/src/components/extension/extensionCard/extensionCard.vue
index bee83552..b1a7ac5d 100644
--- a/apps/client-web/src/components/extension/extensionCard/extensionCard.vue
+++ b/apps/client-web/src/components/extension/extensionCard/extensionCard.vue
@@ -20,6 +20,7 @@ import {
import {
manageExtensionOnPeer,
sortPublishedReleases,
+ preferredPublishedRelease,
RegistryReleaseError,
getExtensionDocumentation,
type ExtensionDocumentationLink,
@@ -229,7 +230,7 @@ async function loadVersions(): Promise {
)
versionModel.value = versionOptions.value.some((option) => option.value === previous?.version)
? previous!.version
- : ''
+ : (preferredPublishedRelease(catalog.releases ?? [])?.version ?? '')
if (!versionOptions.value.length) versionError.value = t('extension.noDistributionRelease')
} catch (error) {
if (request === versionRequest)
@@ -442,6 +443,7 @@ onMounted(refreshRuntime)
:show-cancel="false"
>
Date: Tue, 13 Oct 2026 20:06:17 +0800
Subject: [PATCH 09/27] =?UTF-8?q?fix(twitter):=20=E5=B0=86=E6=8E=88?=
=?UTF-8?q?=E6=9D=83=20Peer=20=E9=80=89=E6=8B=A9=E4=B8=8E=E5=85=B1?=
=?UTF-8?q?=E4=BA=AB=E9=87=87=E9=9B=86=E4=BB=BB=E5=8A=A1=E5=8C=BA=E5=88=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/30-unit-tdd/native-extension-runtime.md | 8 ++++++++
.../twitterSetupWizard/twitterSetupWizard.vue | 14 ++++----------
2 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/docs/30-unit-tdd/native-extension-runtime.md b/docs/30-unit-tdd/native-extension-runtime.md
index af24093b..b7390c79 100644
--- a/docs/30-unit-tdd/native-extension-runtime.md
+++ b/docs/30-unit-tdd/native-extension-runtime.md
@@ -96,6 +96,14 @@ saved token and enabled state. Opening, selecting, and refreshing only read stat
outcomes require refreshing before another attempt. The PAT is masked by default; a normal help
link opens the current browser participation's exact MF release connection guide.
+The Twitter setup contribution selects an authorization Peer for OAuth requests and callbacks. It
+can open from the browser's running MF distribution before the selected Peer has installed Twitter;
+an explicit enable action selects an available Python release when no pin exists, then enables that
+Peer. Existing exact selections are not replaced by that action. Source, Cron and collection Jobs
+remain shared deployment records; choosing the authorization entry does not bind their future
+execution to that Peer. The interface does not call this a collection-Peer binding or attribute its
+schedule timezone to the selected OAuth entry.
+
## Lifecycle and Compensation
The normal lifecycle is:
diff --git a/extensions/twitter/src/components/twitterSetupWizard/twitterSetupWizard.vue b/extensions/twitter/src/components/twitterSetupWizard/twitterSetupWizard.vue
index afae069a..33dbbc6c 100644
--- a/extensions/twitter/src/components/twitterSetupWizard/twitterSetupWizard.vue
+++ b/extensions/twitter/src/components/twitterSetupWizard/twitterSetupWizard.vue
@@ -338,19 +338,16 @@ onBeforeUnmount(() => {