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 @@ - + - - - -
- -
- - - -
- + 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()" + > + -