pi-codex-marketplace 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/extensions/pi/git-registration.ts +138 -0
- package/extensions/pi/index.ts +293 -0
- package/extensions/pi/installation.ts +90 -0
- package/extensions/pi/journal.ts +80 -0
- package/extensions/pi/lifecycle.ts +285 -0
- package/extensions/pi/registration.ts +143 -0
- package/extensions/pi/scope-overrides.ts +170 -0
- package/package.json +60 -0
- package/src/barrier/global-barrier.ts +105 -0
- package/src/bridge-state/atomic.ts +237 -0
- package/src/bridge-state/index.ts +5 -0
- package/src/bridge-state/migrate.ts +261 -0
- package/src/bridge-state/paths.ts +75 -0
- package/src/bridge-state/repair.ts +185 -0
- package/src/bridge-state/schema.ts +70 -0
- package/src/bridge-state/store.ts +489 -0
- package/src/bridge-state/types.ts +170 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/paths.ts +42 -0
- package/src/cache/source-cache.ts +365 -0
- package/src/compatibility/index.ts +1 -0
- package/src/compatibility/profile.ts +328 -0
- package/src/installation/flow.ts +443 -0
- package/src/installation/index.ts +1 -0
- package/src/installation/inspection.ts +129 -0
- package/src/journal/active-chains.ts +99 -0
- package/src/journal/index.ts +3 -0
- package/src/journal/journal.ts +215 -0
- package/src/journal/types.ts +49 -0
- package/src/lifecycle/index.ts +5 -0
- package/src/lifecycle/rebind.ts +290 -0
- package/src/lifecycle/refresh.ts +407 -0
- package/src/lifecycle/removal.ts +457 -0
- package/src/lifecycle/update-plan.ts +222 -0
- package/src/lifecycle/update.ts +303 -0
- package/src/projection/collision.ts +120 -0
- package/src/projection/effective-state.ts +182 -0
- package/src/projection/index.ts +4 -0
- package/src/projection/overrides.ts +230 -0
- package/src/projection/project.ts +359 -0
- package/src/reconciliation/startup.ts +144 -0
- package/src/registration/budget.ts +28 -0
- package/src/registration/catalog.ts +224 -0
- package/src/registration/contained.ts +140 -0
- package/src/registration/fence.ts +86 -0
- package/src/registration/findings.ts +188 -0
- package/src/registration/flow.ts +619 -0
- package/src/registration/git-acquisition.ts +481 -0
- package/src/registration/git-flow.ts +654 -0
- package/src/registration/git-locator.ts +380 -0
- package/src/registration/git-selector.ts +279 -0
- package/src/registration/index.ts +16 -0
- package/src/registration/receipt.ts +305 -0
- package/src/registration/registration.ts +102 -0
- package/src/registration/snapshot.ts +382 -0
- package/src/registration/source-key.ts +111 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** TUI flow for Compatibility Profile v1 Plugin Installation and state toggles. */
|
|
2
|
+
|
|
3
|
+
import type { ExtensionCommandContext, ExtensionUIContext } from '@earendil-works/pi-coding-agent';
|
|
4
|
+
|
|
5
|
+
import { readBridgeState } from '../../src/bridge-state/store.js';
|
|
6
|
+
import {
|
|
7
|
+
confirmPluginEnable,
|
|
8
|
+
confirmPluginInstallation,
|
|
9
|
+
disablePluginInstallation,
|
|
10
|
+
installationDisclosure,
|
|
11
|
+
preflightPluginEnable,
|
|
12
|
+
preflightPluginInstallation,
|
|
13
|
+
type InstallationOutcome,
|
|
14
|
+
} from '../../src/installation/flow.js';
|
|
15
|
+
import { inspectMarketplaceEntries } from '../../src/installation/inspection.js';
|
|
16
|
+
import type { Registration, Scope } from '../../src/bridge-state/types.js';
|
|
17
|
+
import { reportOutcome } from './registration.js';
|
|
18
|
+
|
|
19
|
+
interface EntryChoice { label: string; pointer?: string }
|
|
20
|
+
|
|
21
|
+
function labelText(value: string): string { return JSON.stringify(value); }
|
|
22
|
+
|
|
23
|
+
export async function entryChoices(
|
|
24
|
+
registration: Registration,
|
|
25
|
+
scope: Scope,
|
|
26
|
+
_opts: { cwd?: string; projectTrusted?: boolean },
|
|
27
|
+
): Promise<EntryChoice[]> {
|
|
28
|
+
const inspection = inspectMarketplaceEntries(registration, scope);
|
|
29
|
+
if (!inspection.marketplaceId) return [{ label: `${labelText(registration.alias ?? registration.id)} — Unavailable (${labelText(inspection.findings[0]?.outcome ?? 'Marketplace Catalog cannot be read')})` }];
|
|
30
|
+
return inspection.entries.map((item) => {
|
|
31
|
+
const status = item.unavailableReason ? `Unavailable (${item.unavailableReason})` : '可安裝';
|
|
32
|
+
return { label: `${inspection.marketplaceId}${item.entry.entryId} · ${labelText(item.entry.name ?? item.plugin?.manifestName ?? 'unnamed')} — ${labelText(status)}`, pointer: item.unavailableReason ? undefined : item.entry.entryId };
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function runPluginInstallationFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
37
|
+
const ui: ExtensionUIContext = ctx.ui;
|
|
38
|
+
const scopeChoice = await ui.select('Plugin Installation — 選擇 Scope', ['Global Scope', 'Project Scope']);
|
|
39
|
+
if (!scopeChoice) return;
|
|
40
|
+
const scope: Scope = scopeChoice.startsWith('Global') ? 'global' : 'project';
|
|
41
|
+
const opts = { cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() };
|
|
42
|
+
const state = await readBridgeState(scope, opts);
|
|
43
|
+
if (state.status !== 'ok' && state.status !== 'missing') return void ui.notify(`Bridge State 不可讀:${state.error ?? 'Persistence Indeterminate'}`, 'error');
|
|
44
|
+
const registrations = state.state?.registrations ?? [];
|
|
45
|
+
if (registrations.length === 0) return void ui.notify('此 Scope 尚無 Marketplace Registration。', 'info');
|
|
46
|
+
const labels = registrations.map((registration) => `${registration.alias ?? registration.marketplaceName ?? registration.id} · ${registration.id}`);
|
|
47
|
+
const selectedLabel = await ui.select('選擇已註冊 Marketplace', labels);
|
|
48
|
+
if (!selectedLabel) return;
|
|
49
|
+
const registration = registrations[labels.indexOf(selectedLabel)]!;
|
|
50
|
+
const choices = await entryChoices(registration, scope, opts);
|
|
51
|
+
const entryLabel = await ui.select('Marketplace Entries(顯示 Marketplace Entry ID 與可安裝/Unavailable 原因)', choices.map((item) => item.label));
|
|
52
|
+
if (!entryLabel) return;
|
|
53
|
+
const selected = choices.find((item) => item.label === entryLabel);
|
|
54
|
+
if (!selected?.pointer) return void ui.notify('此 Marketplace Entry 為 Unavailable,無法安裝。', 'warning');
|
|
55
|
+
const preflight = await preflightPluginInstallation(scope, registration.id, selected.pointer, opts);
|
|
56
|
+
if (!preflight.ok) return reportOutcome(ctx, preflight.outcome);
|
|
57
|
+
const path = await ui.select('Installation path', ['Install Disabled', 'Install and Enable']);
|
|
58
|
+
if (!path) {
|
|
59
|
+
preflight.preflight.fence.release();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const disclosure = installationDisclosure(preflight.preflight);
|
|
63
|
+
if (path === 'Install Disabled') {
|
|
64
|
+
ui.notify(`Validation Disclosure:\n${disclosure}\n\nInstall Disabled does not request Activation Confirmation.`, 'info');
|
|
65
|
+
return reportOutcome(ctx, await confirmPluginInstallation(preflight.preflight, 'disabled', opts));
|
|
66
|
+
}
|
|
67
|
+
const activate = await ui.confirm('Activation Confirmation — 預設 No(獨立於 Registration Confirmation)', `Validation Disclosure:\n${disclosure}\n\n確認安裝並啟用 ${preflight.preflight.plugin.manifestName}?`);
|
|
68
|
+
reportOutcome(ctx, await confirmPluginInstallation(preflight.preflight, 'enabled', activate, opts));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function runPluginStateFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
72
|
+
const ui: ExtensionUIContext = ctx.ui;
|
|
73
|
+
const scopeChoice = await ui.select('Installed Plugin — 選擇 Scope', ['Global Scope', 'Project Scope']);
|
|
74
|
+
if (!scopeChoice) return;
|
|
75
|
+
const scope: Scope = scopeChoice.startsWith('Global') ? 'global' : 'project';
|
|
76
|
+
const opts = { cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() };
|
|
77
|
+
const state = await readBridgeState(scope, opts);
|
|
78
|
+
if (state.status !== 'ok' && state.status !== 'missing') return void ui.notify(`Bridge State 不可讀:${state.error ?? 'Persistence Indeterminate'}`, 'error');
|
|
79
|
+
const installations = state.state!.installations;
|
|
80
|
+
if (installations.length === 0) return void ui.notify('此 Scope 尚無 Installed Plugin。', 'info');
|
|
81
|
+
const labels = installations.map((item) => `${item.manifestName ?? item.pluginId} · ${item.installationState} · ${item.id}`);
|
|
82
|
+
const chosen = await ui.select('選擇 Installed Plugin', labels);
|
|
83
|
+
if (!chosen) return;
|
|
84
|
+
const installation = installations[labels.indexOf(chosen)]!;
|
|
85
|
+
if (installation.installationState === 'enabled') return reportOutcome(ctx, await disablePluginInstallation(scope, installation.id, opts));
|
|
86
|
+
const preflight = await preflightPluginEnable(scope, installation.id, opts);
|
|
87
|
+
if (!preflight.ok) return reportOutcome(ctx, preflight.outcome);
|
|
88
|
+
const confirmed = await ui.confirm('Activation Confirmation — 預設 No(重新驗證後才可 re-enable)', installationDisclosure(preflight.preflight));
|
|
89
|
+
reportOutcome(ctx, await confirmPluginEnable(preflight.preflight, confirmed, opts));
|
|
90
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI flows for Receipt Journal inspection and State Repair action (Issue #23).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionCommandContext, ExtensionUIContext } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
|
|
7
|
+
import type { Scope } from '../../src/bridge-state/types.js';
|
|
8
|
+
import { readReceiptJournal } from '../../src/journal/journal.js';
|
|
9
|
+
import { repairBridgeState } from '../../src/bridge-state/repair.js';
|
|
10
|
+
import { formatThreeOrthogonalReport } from '../../src/registration/receipt.js';
|
|
11
|
+
import { reportOutcome } from './registration.js';
|
|
12
|
+
|
|
13
|
+
export async function runReceiptJournalView(ctx: ExtensionCommandContext): Promise<void> {
|
|
14
|
+
const ui: ExtensionUIContext = ctx.ui;
|
|
15
|
+
const scopeChoice = await ui.select('Receipt Journal — 選擇 Scope', [
|
|
16
|
+
'Global Scope',
|
|
17
|
+
'Project Scope',
|
|
18
|
+
]);
|
|
19
|
+
if (!scopeChoice) return;
|
|
20
|
+
const scope: Scope = scopeChoice.startsWith('Global') ? 'global' : 'project';
|
|
21
|
+
|
|
22
|
+
const journal = await readReceiptJournal(scope, { cwd: ctx.cwd });
|
|
23
|
+
const lines: string[] = [
|
|
24
|
+
`=== ${scope === 'global' ? 'Global' : 'Project'} Receipt Journal ===`,
|
|
25
|
+
`Total Receipts: ${journal.receipts.length}`,
|
|
26
|
+
`Degraded: ${journal.isDegraded ? `Yes (${journal.corruptedLineCount} corrupted lines)` : 'No'}`,
|
|
27
|
+
`Active Recovery Chains: ${journal.activeChains.length === 0 ? 'None' : ''}`,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
for (const chain of journal.activeChains) {
|
|
31
|
+
lines.push(` • Chain [${chain.rootReceiptId}] condition: ${chain.condition} (length: ${chain.receipts.length})`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
lines.push('');
|
|
35
|
+
lines.push('--- Recent Receipts ---');
|
|
36
|
+
const recent = journal.receipts.slice(-10).reverse();
|
|
37
|
+
if (recent.length === 0) {
|
|
38
|
+
lines.push(' (Journal is empty)');
|
|
39
|
+
} else {
|
|
40
|
+
for (const rc of recent) {
|
|
41
|
+
lines.push(`[${rc.id}] ${rc.completedAt} · ${rc.operation} (${rc.scope})`);
|
|
42
|
+
lines.push(` Summary: ${rc.summary} | Durable: ${rc.durableOutcome} | Runtime: ${rc.runtimeOutcome}`);
|
|
43
|
+
lines.push(` Revision: ${rc.expectedStateRevision} → ${rc.observedStateRevision ?? rc.targetStateRevision ?? '?'}`);
|
|
44
|
+
if (rc.recoversReceiptId) {
|
|
45
|
+
lines.push(` Recovers: ${rc.recoversReceiptId}`);
|
|
46
|
+
}
|
|
47
|
+
if (rc.findings.length > 0) {
|
|
48
|
+
lines.push(` Findings: ${rc.findings.map((f) => `${f.classification} ${f.code}`).join(', ')}`);
|
|
49
|
+
}
|
|
50
|
+
lines.push('');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
ui.notify(lines.join('\n'), journal.isDegraded ? 'warning' : 'info');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function runRepairStateFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
58
|
+
const ui: ExtensionUIContext = ctx.ui;
|
|
59
|
+
const scopeChoice = await ui.select('Repair State — 選擇 Scope', [
|
|
60
|
+
'Global Scope',
|
|
61
|
+
'Project Scope',
|
|
62
|
+
]);
|
|
63
|
+
if (!scopeChoice) return;
|
|
64
|
+
const scope: Scope = scopeChoice.startsWith('Global') ? 'global' : 'project';
|
|
65
|
+
|
|
66
|
+
const confirmed = await ui.confirm(
|
|
67
|
+
'Repair State Confirmation — 預設 No',
|
|
68
|
+
`執行 ${scope === 'global' ? 'Global' : 'Project'} Scope 的 State Repair?\n將在 Attempt Fence 保護下驗證 Bridge State 結構與一致性,並解除相應的 Indeterminate/Degraded recovery chain。`,
|
|
69
|
+
);
|
|
70
|
+
if (!confirmed) {
|
|
71
|
+
ui.notify('已取消 State Repair', 'info');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const res = await repairBridgeState(scope, {
|
|
76
|
+
cwd: ctx.cwd,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
reportOutcome(ctx, { receipt: res.receipt });
|
|
80
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI flows for the #21 lifecycle: Marketplace Refresh → Update Candidate → Update Plan
|
|
3
|
+
* Checklist → Apply Update, plus Registration Rebind and Registration / Installation Removal.
|
|
4
|
+
*
|
|
5
|
+
* All consent surfaces Default No and are bound to Validation Snapshot + State Revision by the
|
|
6
|
+
* underlying src/lifecycle seams; this layer only collects explicit user outcomes and reports
|
|
7
|
+
* Attempt Summary receipts.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionCommandContext, ExtensionUIContext } from '@earendil-works/pi-coding-agent';
|
|
11
|
+
|
|
12
|
+
import { readBridgeState } from '../../src/bridge-state/store.js';
|
|
13
|
+
import type { Installation, Registration, Scope } from '../../src/bridge-state/types.js';
|
|
14
|
+
import {
|
|
15
|
+
applyUpdate,
|
|
16
|
+
preflightRebind,
|
|
17
|
+
refreshRegistration,
|
|
18
|
+
registrationRemovalDisclosure,
|
|
19
|
+
installationRemovalDisclosure,
|
|
20
|
+
confirmRegistrationRemoval,
|
|
21
|
+
confirmInstallationRemoval,
|
|
22
|
+
preflightRegistrationRemoval,
|
|
23
|
+
preflightInstallationRemoval,
|
|
24
|
+
type LifecycleFlowOptions,
|
|
25
|
+
type RebindTarget,
|
|
26
|
+
type UpdateCandidate,
|
|
27
|
+
} from '../../src/lifecycle/index.js';
|
|
28
|
+
import { buildUpdatePlan, compatibleCandidateIds, type InstallationChoice } from '../../src/lifecycle/update-plan.js';
|
|
29
|
+
|
|
30
|
+
function quote(value: string): string {
|
|
31
|
+
return JSON.stringify(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Pure helper: per-installation choice options, 'update' only when a Compatible candidate exists. */
|
|
35
|
+
export function planChoicesFor(
|
|
36
|
+
installations: Installation[],
|
|
37
|
+
candidate: UpdateCandidate,
|
|
38
|
+
): { installation: Installation; options: { label: string; value: InstallationChoice; enabled: boolean }[] }[] {
|
|
39
|
+
const compatible = compatibleCandidateIds(candidate);
|
|
40
|
+
return installations.map((installation) => ({
|
|
41
|
+
installation,
|
|
42
|
+
options: [
|
|
43
|
+
{ label: `update — 套用新快照(${compatible.has(installation.pluginId) ? '有 Compatible candidate' : '無 candidate → 不可選'})`, value: 'update', enabled: compatible.has(installation.pluginId) },
|
|
44
|
+
{ label: 'disable — 停用並保留 Installation ID', value: 'disable', enabled: true },
|
|
45
|
+
{ label: 'remove — 移除此 Installation', value: 'remove', enabled: true },
|
|
46
|
+
],
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Pure helper: candidate disclosure summary for the checklist surface. */
|
|
51
|
+
export function candidateSummary(candidate: UpdateCandidate): string {
|
|
52
|
+
const lines = [
|
|
53
|
+
`Scope: ${candidate.scope}`,
|
|
54
|
+
`Registration: ${candidate.registrationId.slice(0, 8)}…`,
|
|
55
|
+
`Marketplace: ${quote(candidate.marketplaceName || '(unchanged name)')}`,
|
|
56
|
+
`New Validation Snapshot: ${candidate.snapshot.fingerprint.slice(0, 16)}…`,
|
|
57
|
+
`Recorded Snapshot: ${(candidate.recordedFingerprint ?? '(none)').slice(0, 16)}…`,
|
|
58
|
+
];
|
|
59
|
+
if (candidate.resolvedRevision) {
|
|
60
|
+
lines.push(`Resolved Revision: ${candidate.recordedResolvedRevision ?? '(none)'.slice(0, 12)} → ${candidate.resolvedRevision}`);
|
|
61
|
+
}
|
|
62
|
+
const available = candidate.inspection.entries.filter((item) => item.plugin && !item.unavailableReason).length;
|
|
63
|
+
lines.push(`Entries: ${candidate.inspection.entries.length}(${available} 可安裝)`);
|
|
64
|
+
const blocking = candidate.inspection.findings.filter((f) => f.classification === 'blocking');
|
|
65
|
+
lines.push(`Findings: ${blocking.length} blocking`);
|
|
66
|
+
for (const entry of candidate.inspection.entries) {
|
|
67
|
+
lines.push(` ${entry.entry.entryId} ${entry.plugin ? `· ${quote(entry.plugin.manifestName)} ` : ''}— ${entry.unavailableReason ? `unavailable (${entry.unavailableReason})` : '可安裝'}`);
|
|
68
|
+
}
|
|
69
|
+
return lines.join('\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function attemptReport(ctx: ExtensionCommandContext, outcome: { status: string; receipt?: { id: string; summary: string }; newRevision?: string; isIndeterminate?: boolean; findings?: { code: string; outcome: string }[] }): void {
|
|
73
|
+
if (outcome.status === 'completed') {
|
|
74
|
+
ctx.ui.notify(`Attempt Summary: ${outcome.receipt?.summary ?? 'Completed'} · State Revision ${outcome.newRevision}\nReceipt ${outcome.receipt?.id} — immutable, non-authoritative.`, 'info');
|
|
75
|
+
} else if (outcome.status === 'declined') {
|
|
76
|
+
ctx.ui.notify(`Attempt Summary: Declined — state unchanged. Receipt ${outcome.receipt?.id}`, 'info');
|
|
77
|
+
} else if (outcome.status === 'rejected-as-stale') {
|
|
78
|
+
ctx.ui.notify('Attempt Summary: Rejected as Stale — 重新執行 Refresh 與確認;不自動合併。', 'warning');
|
|
79
|
+
} else if (outcome.status === 'persistence-failed') {
|
|
80
|
+
ctx.ui.notify(`Attempt Summary: ${outcome.isIndeterminate ? 'Persistence Indeterminate' : 'Persistence Failed'} — Bridge State 未變更。`, 'error');
|
|
81
|
+
} else {
|
|
82
|
+
const first = outcome.findings?.[0];
|
|
83
|
+
ctx.ui.notify(`Attempt Summary: Blocked — ${first?.code ?? '?'}: ${first?.outcome ?? ''}`, 'error');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function pickScope(ui: ExtensionUIContext): Promise<Scope | undefined> {
|
|
88
|
+
const choice = await ui.select('選擇 Scope', ['Global Scope', 'Project Scope']);
|
|
89
|
+
if (!choice) return undefined;
|
|
90
|
+
return choice.startsWith('Global') ? 'global' : 'project';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function pickRegistration(ctx: ExtensionCommandContext, scope: Scope): Promise<{ registration: Registration; opts: LifecycleFlowOptions } | undefined> {
|
|
94
|
+
const ui = ctx.ui;
|
|
95
|
+
const opts: LifecycleFlowOptions = { cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() };
|
|
96
|
+
const state = await readBridgeState(scope, opts);
|
|
97
|
+
if (state.status !== 'ok' && state.status !== 'missing') {
|
|
98
|
+
ui.notify(`Bridge State 不可讀:${state.error ?? 'Persistence Indeterminate'}`, 'error');
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
const registrations = state.state?.registrations ?? [];
|
|
102
|
+
if (registrations.length === 0) {
|
|
103
|
+
ui.notify('此 Scope 尚無 Marketplace Registration。', 'info');
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const labels = registrations.map((r) => `${r.alias ?? r.marketplaceName ?? r.id} · ${r.sourceKind ?? '?'} · ${r.id}`);
|
|
107
|
+
const chosen = await ui.select('選擇 Marketplace Registration', labels);
|
|
108
|
+
if (!chosen) return undefined;
|
|
109
|
+
return { registration: registrations[labels.indexOf(chosen)]!, opts };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Update Plan Checklist — collects fresh Registration Confirmation, one explicit outcome per
|
|
114
|
+
* existing Installation, and an Activation Confirmation per enabled installation that remains
|
|
115
|
+
* enabled. Submits only when the complete plan validates.
|
|
116
|
+
*/
|
|
117
|
+
export async function runUpdatePlanChecklist(
|
|
118
|
+
ctx: ExtensionCommandContext,
|
|
119
|
+
scope: Scope,
|
|
120
|
+
candidate: UpdateCandidate,
|
|
121
|
+
stateRevision: string,
|
|
122
|
+
kind: 'apply-update' | 'rebind',
|
|
123
|
+
rebindSource?: Parameters<typeof buildUpdatePlan>[3] extends never ? never : NonNullable<Parameters<typeof buildUpdatePlan>[3]['rebindSource']>,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
const ui = ctx.ui;
|
|
126
|
+
const opts: LifecycleFlowOptions = { cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() };
|
|
127
|
+
const state = await readBridgeState(scope, opts);
|
|
128
|
+
if (state.status !== 'ok' && state.status !== 'missing') return void ui.notify(`Bridge State 不可讀:${state.error ?? 'Persistence Indeterminate'}`, 'error');
|
|
129
|
+
|
|
130
|
+
// Strictly this Registration's own scope-local Installations — independent Registrations and
|
|
131
|
+
// Installations are never combined into a batch (CONTEXT.md: Lifecycle Operation).
|
|
132
|
+
const installations = (state.state?.installations ?? []).filter((i) => i.registrationId === candidate.registrationId);
|
|
133
|
+
|
|
134
|
+
ui.notify(`Validation Disclosure(新快照):\n${candidateSummary(candidate)}`, 'info');
|
|
135
|
+
|
|
136
|
+
// Fresh Registration Confirmation — Default No, bound to the candidate snapshot + revision.
|
|
137
|
+
const registrationConfirmed = await ui.confirm(
|
|
138
|
+
'Registration Confirmation — 預設 No(綁定新 Validation Snapshot + State Revision)',
|
|
139
|
+
`接受此新的 Validation Snapshot 作為 Registration ${candidate.registrationId.slice(0, 8)}… 的授權來源?`,
|
|
140
|
+
);
|
|
141
|
+
if (!registrationConfirmed) return void ui.notify('Attempt Summary: Declined — 未取得 Registration Confirmation,狀態未變更。', 'info');
|
|
142
|
+
|
|
143
|
+
// One explicit outcome per existing Installation — never batched, no default.
|
|
144
|
+
const choices: Record<string, InstallationChoice> = {};
|
|
145
|
+
const activationConfirmations: Record<string, boolean> = {};
|
|
146
|
+
for (const { installation, options } of planChoicesFor(installations, candidate)) {
|
|
147
|
+
const selectable = options.filter((o) => o.enabled);
|
|
148
|
+
const picked = await ui.select(
|
|
149
|
+
`Installation ${installation.manifestName ?? installation.pluginId}(${installation.installationState})— 選擇更新結果`,
|
|
150
|
+
selectable.map((o) => o.label),
|
|
151
|
+
);
|
|
152
|
+
if (!picked) return void ui.notify('已取消 — Update Plan 放棄(每個 Installation 皆需明確抉擇)。', 'info');
|
|
153
|
+
choices[installation.id] = selectable.find((o) => o.label === picked)!.value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Activation Confirmation per enabled installation that remains enabled — Default No.
|
|
157
|
+
for (const installation of installations) {
|
|
158
|
+
const willStayEnabled = installation.installationState === 'enabled' && choices[installation.id] === 'update';
|
|
159
|
+
if (!willStayEnabled) continue;
|
|
160
|
+
const confirmed = await ui.confirm(
|
|
161
|
+
'Activation Confirmation — 預設 No(舊同意不沿用)',
|
|
162
|
+
`啟用的 ${installation.manifestName ?? installation.pluginId} 將在新快照下保持啟用。確認其 Activation?`,
|
|
163
|
+
);
|
|
164
|
+
activationConfirmations[installation.id] = confirmed;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const plan = buildUpdatePlan(candidate, installations, stateRevision, {
|
|
168
|
+
registrationConfirmed,
|
|
169
|
+
kind,
|
|
170
|
+
rebindSource,
|
|
171
|
+
choices,
|
|
172
|
+
activationConfirmations,
|
|
173
|
+
});
|
|
174
|
+
if (!plan.ok) {
|
|
175
|
+
return void ui.notify(`Update Plan 無法成立(放棄提交):\n${plan.problems.map((p) => `- [${p.code}] ${p.outcome}`).join('\n')}`, 'warning');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Final checklist review before the single atomic commit.
|
|
179
|
+
const checklist = plan.plan.entries
|
|
180
|
+
.map((entry) => `· ${entry.installationId.slice(0, 16)}… → ${entry.choice}${entry.choice === 'update' ? `(${entry.installationState})` : ''}`)
|
|
181
|
+
.join('\n');
|
|
182
|
+
const proceed = await ui.confirm(
|
|
183
|
+
kind === 'rebind' ? 'Apply Rebind — 單次原子提交' : 'Apply Update — 單次原子提交',
|
|
184
|
+
`將以單一 Lifecycle Operation 原子替換快照並套用所有披露後果:\n${checklist || '(無既有 Installation)'}\n\n確認提交?`,
|
|
185
|
+
);
|
|
186
|
+
if (!proceed) return void ui.notify('Attempt Summary: Declined — 未提交。', 'info');
|
|
187
|
+
|
|
188
|
+
attemptReport(ctx, await applyUpdate(plan.plan, opts));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Marketplace Refresh on a single Registration — non-mutating; produces an Update Candidate or reports no change. */
|
|
192
|
+
export async function runRefreshFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
193
|
+
const ui = ctx.ui;
|
|
194
|
+
const scope = await pickScope(ui);
|
|
195
|
+
if (!scope) return;
|
|
196
|
+
const picked = await pickRegistration(ctx, scope);
|
|
197
|
+
if (!picked) return;
|
|
198
|
+
|
|
199
|
+
const outcome = await refreshRegistration(scope, picked.registration.id, picked.opts);
|
|
200
|
+
if (outcome.status === 'no-change') {
|
|
201
|
+
ui.notify(`Attempt Summary: Completed — 無變更(recorded snapshot 仍為權威)。\nReceipt ${outcome.receipt.id}`, 'info');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (outcome.status === 'blocked') {
|
|
205
|
+
const first = outcome.findings[0];
|
|
206
|
+
return void ui.notify(`Attempt Summary: Blocked — ${first?.code}: ${first?.outcome}`, 'error');
|
|
207
|
+
}
|
|
208
|
+
ui.notify('Update Candidate 已產生(非變異檢查,Bridge State 未寫入)。', 'info');
|
|
209
|
+
// Bind the plan to the exact State Revision the candidate was validated against.
|
|
210
|
+
await runUpdatePlanChecklist(ctx, scope, outcome.candidate, outcome.candidate.stateRevision, 'apply-update');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Registration Rebind — replace locator/selector under the preserved Registration ID. */
|
|
214
|
+
export async function runRebindFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
215
|
+
const ui = ctx.ui;
|
|
216
|
+
const scope = await pickScope(ui);
|
|
217
|
+
if (!scope) return;
|
|
218
|
+
const picked = await pickRegistration(ctx, scope);
|
|
219
|
+
if (!picked) return;
|
|
220
|
+
|
|
221
|
+
const kindChoice = await ui.select('新來源型別', ['本地目錄(local path)', 'Git 倉庫(locator + selector)']);
|
|
222
|
+
if (!kindChoice) return;
|
|
223
|
+
|
|
224
|
+
let target: RebindTarget;
|
|
225
|
+
if (kindChoice.startsWith('本地')) {
|
|
226
|
+
const rootPath = await ui.input('新的本地 Marketplace Root 路徑', '/path/to/marketplace');
|
|
227
|
+
if (!rootPath) return void ui.notify('已取消 Rebind。', 'info');
|
|
228
|
+
target = { kind: 'local', rootPath };
|
|
229
|
+
} else {
|
|
230
|
+
const locator = await ui.input('Git Locator(https:// 或 ssh,無憑證、無 query/fragment)', 'https://github.com/owner/repo.git');
|
|
231
|
+
if (!locator) return void ui.notify('已取消 Rebind。', 'info');
|
|
232
|
+
const selectorKind = await ui.select('Git Selector 型別', ['default', 'branch', 'tag', 'commit']);
|
|
233
|
+
if (!selectorKind) return void ui.notify('已取消 Rebind。', 'info');
|
|
234
|
+
let selector: Extract<RebindTarget, { kind: 'git' }>['selector'] = 'default';
|
|
235
|
+
if (selectorKind === 'branch' || selectorKind === 'tag' || selectorKind === 'commit') {
|
|
236
|
+
const placeholder = selectorKind === 'commit' ? '完整 40/64 hex commit' : selectorKind === 'tag' ? 'v1.2.3' : 'main';
|
|
237
|
+
const value = await ui.input(`${selectorKind} 值(例:${placeholder})`, placeholder);
|
|
238
|
+
if (!value) return void ui.notify('已取消 Rebind。', 'info');
|
|
239
|
+
selector = { kind: selectorKind, value };
|
|
240
|
+
}
|
|
241
|
+
target = { kind: 'git', locator, selector };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const pf = await preflightRebind(scope, picked.registration.id, target, picked.opts);
|
|
245
|
+
if (!pf.ok) {
|
|
246
|
+
const first = pf.outcome.findings?.[0];
|
|
247
|
+
return void ui.notify(`Attempt Summary: Blocked — ${first?.code ?? '?'}: ${first?.outcome ?? ''}`, 'error');
|
|
248
|
+
}
|
|
249
|
+
ui.notify('替代來源已完成完整重驗證;需重新收集全部確認(舊 Activation 同意不沿用)。', 'info');
|
|
250
|
+
// Rebind binds to the revision observed while validating the replacement source.
|
|
251
|
+
await runUpdatePlanChecklist(ctx, scope, pf.preflight.candidate, pf.preflight.stateRevision, 'rebind', pf.preflight.rebindSource);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Removal flows with full cascade disclosure, Default No. */
|
|
255
|
+
export async function runRemovalFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
256
|
+
const ui = ctx.ui;
|
|
257
|
+
const scope = await pickScope(ui);
|
|
258
|
+
if (!scope) return;
|
|
259
|
+
const what = await ui.select('移除目標', ['整個 Registration(原子刪除同範圍所有 Installations)', '單一 Installation(保留 Registration)']);
|
|
260
|
+
if (!what) return;
|
|
261
|
+
const opts = { cwd: ctx.cwd, agentDir: undefined as string | undefined, projectTrusted: ctx.isProjectTrusted() };
|
|
262
|
+
|
|
263
|
+
if (what.startsWith('整個')) {
|
|
264
|
+
const picked = await pickRegistration(ctx, scope);
|
|
265
|
+
if (!picked) return;
|
|
266
|
+
const pf = await preflightRegistrationRemoval(scope, picked.registration.id, opts);
|
|
267
|
+
if (!pf.ok) return attemptReport(ctx, pf.outcome);
|
|
268
|
+
const proceed = await ui.confirm('Registration Removal — 預設 No', registrationRemovalDisclosure(pf.preflight));
|
|
269
|
+
attemptReport(ctx, await confirmRegistrationRemoval(pf.preflight, proceed, opts));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const state = await readBridgeState(scope, opts);
|
|
274
|
+
if (state.status !== 'ok' && state.status !== 'missing') return void ui.notify(`Bridge State 不可讀:${state.error ?? 'Persistence Indeterminate'}`, 'error');
|
|
275
|
+
const installations = state.state?.installations ?? [];
|
|
276
|
+
if (installations.length === 0) return void ui.notify('此 Scope 尚無 Installed Plugin。', 'info');
|
|
277
|
+
const labels = installations.map((i) => `${i.manifestName ?? i.pluginId} · ${i.installationState} · ${i.id}`);
|
|
278
|
+
const chosen = await ui.select('選擇要移除的 Installation', labels);
|
|
279
|
+
if (!chosen) return;
|
|
280
|
+
const installation = installations[labels.indexOf(chosen)]!;
|
|
281
|
+
const pf = await preflightInstallationRemoval(scope, installation.id, opts);
|
|
282
|
+
if (!pf.ok) return attemptReport(ctx, pf.outcome);
|
|
283
|
+
const proceed = await ui.confirm('Installation Removal — 預設 No', installationRemovalDisclosure(pf.preflight));
|
|
284
|
+
attemptReport(ctx, await confirmInstallationRemoval(pf.preflight, proceed, opts));
|
|
285
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Marketplace Registration — interactive TUI flow (Issue #17).
|
|
3
|
+
* Prototype contract (tui-management-flow): explicit scope selection → Validation Disclosure →
|
|
4
|
+
* Registration Confirmation (Validation Snapshot + State Revision bound, Default No) → atomic
|
|
5
|
+
* commit → Attempt Summary + closed Recovery Action reporting.
|
|
6
|
+
*
|
|
7
|
+
* The flow logic itself lives in src/registration/flow.ts (the tested seam); this file renders it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Theme } from '@earendil-works/pi-coding-agent';
|
|
11
|
+
import type { ExtensionCommandContext, ExtensionUIContext } from '@earendil-works/pi-coding-agent';
|
|
12
|
+
import { truncateToWidth } from '@earendil-works/pi-tui';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
preflightLocalRegistration,
|
|
16
|
+
confirmLocalRegistration,
|
|
17
|
+
disclosureSummary,
|
|
18
|
+
type LocalRegistrationPreflight,
|
|
19
|
+
} from '../../src/registration/flow.js';
|
|
20
|
+
import type { ValidationFinding } from '../../src/registration/findings.js';
|
|
21
|
+
import type { RegistrationOutcome } from '../../src/registration/flow.js';
|
|
22
|
+
import { formatThreeOrthogonalReport, type AttemptReceipt } from '../../src/registration/receipt.js';
|
|
23
|
+
|
|
24
|
+
export function formatFindings(findings: ValidationFinding[]): string[] {
|
|
25
|
+
const sorted = [...findings].sort((a, b) => {
|
|
26
|
+
const rank: Record<string, number> = { blocking: 0, warning: 1, notice: 2 };
|
|
27
|
+
const phaseRank: Record<string, number> = { admission: 0, identity: 1, validation: 2, persistence: 3, 'post-commit': 4 };
|
|
28
|
+
return (
|
|
29
|
+
(rank[a.classification] ?? 9) - (rank[b.classification] ?? 9) ||
|
|
30
|
+
(phaseRank[a.phase] ?? 9) - (phaseRank[b.phase] ?? 9) ||
|
|
31
|
+
a.target.localeCompare(b.target) ||
|
|
32
|
+
a.pointer.localeCompare(b.pointer) ||
|
|
33
|
+
a.rule.localeCompare(b.rule)
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
return sorted.map((f) => {
|
|
37
|
+
const cls = f.classification === 'blocking' ? 'BLOCKING' : f.classification === 'warning' ? 'WARNING' : 'NOTICE';
|
|
38
|
+
const ptr = f.pointer ? ` @${f.pointer}` : '';
|
|
39
|
+
return ` [${cls}] ${f.code} (${f.rule}) · ${f.target}${ptr} — ${f.outcome}`;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Static disclosure view (mirrors the scaffold component pattern). */
|
|
44
|
+
class DisclosureComponent {
|
|
45
|
+
private lines: string[];
|
|
46
|
+
private theme: Theme;
|
|
47
|
+
private onClose: () => void;
|
|
48
|
+
|
|
49
|
+
constructor(lines: string[], theme: Theme, onClose: () => void) {
|
|
50
|
+
this.lines = lines;
|
|
51
|
+
this.theme = theme;
|
|
52
|
+
this.onClose = onClose;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
handleInput(data: string): void {
|
|
56
|
+
if (data) this.onClose();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
render(width: number): string[] {
|
|
60
|
+
const th = this.theme;
|
|
61
|
+
const out: string[] = [];
|
|
62
|
+
out.push('');
|
|
63
|
+
out.push(truncateToWidth(th.fg('accent', th.bold(' Validation Disclosure ')) + th.fg('borderMuted', '─'.repeat(Math.max(0, width - 22))), width));
|
|
64
|
+
for (const ln of this.lines) {
|
|
65
|
+
out.push(truncateToWidth(` ${ln}`, width));
|
|
66
|
+
}
|
|
67
|
+
out.push('');
|
|
68
|
+
out.push(truncateToWidth(` ${th.fg('dim', 'Any key: continue to Registration Confirmation (Default No) · Confirm is snapshot + State Revision bound')}`, width));
|
|
69
|
+
out.push('');
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
invalidate(): void {}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One interactive registration flow invocation. */
|
|
77
|
+
export async function runLocalRegistrationFlow(ctx: ExtensionCommandContext): Promise<void> {
|
|
78
|
+
const ui: ExtensionUIContext = ctx.ui;
|
|
79
|
+
const scopeChoice = await ui.select('Marketplace Registration — 選擇 Scope', [
|
|
80
|
+
'Global Scope',
|
|
81
|
+
'Project Scope',
|
|
82
|
+
]);
|
|
83
|
+
if (!scopeChoice) {
|
|
84
|
+
ui.notify('已取消 Registration', 'info');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const scope: 'global' | 'project' = scopeChoice.startsWith('Global') ? 'global' : 'project';
|
|
88
|
+
|
|
89
|
+
const rootPath = await ui.input('本地 Marketplace Root(需含 .agents/plugins/marketplace.json)', '.');
|
|
90
|
+
if (!rootPath) {
|
|
91
|
+
ui.notify('已取消 Registration', 'info');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const opts = { cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() };
|
|
96
|
+
const res = await preflightLocalRegistration(scope, rootPath, opts);
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
reportOutcome(ctx, res.outcome);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const pf = res.preflight;
|
|
103
|
+
// Validation Disclosure → confirmation
|
|
104
|
+
const lines = [
|
|
105
|
+
...disclosureSummary(pf).split('\n'),
|
|
106
|
+
'',
|
|
107
|
+
...formatFindings(pf.findings),
|
|
108
|
+
];
|
|
109
|
+
const disclosure: string[] = lines;
|
|
110
|
+
|
|
111
|
+
if (ctx.mode !== 'tui') {
|
|
112
|
+
ui.notify('Registration 需要 TUI 模式; disclosure:\n' + lines.join('\n'), 'info');
|
|
113
|
+
} else {
|
|
114
|
+
await ui.custom<void>(
|
|
115
|
+
(_tui, theme, _kb, done) =>
|
|
116
|
+
new DisclosureComponent(disclosure, theme, () => done(undefined)),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const yes = await ui.confirm(
|
|
121
|
+
'Registration Confirmation — 預設 No(綁定 State Revision + Validation Snapshot,不可記憶、不可批次)',
|
|
122
|
+
`確認註冊 ${pf.canonicalPath} 至 ${scope}?\n${disclosure.slice(0, 8).join('\n')}`,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const outcome = await confirmLocalRegistration(pf, yes, opts);
|
|
126
|
+
reportOutcome(ctx, outcome);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Render the three-orthogonal outcome (persistence / findings / runtime) as an Attempt Summary + Recovery Action. */
|
|
130
|
+
export function reportOutcome(
|
|
131
|
+
ctx: { ui: { notify(message: string, type?: 'info' | 'warning' | 'error'): void } },
|
|
132
|
+
outcome: { receipt: AttemptReceipt },
|
|
133
|
+
): void {
|
|
134
|
+
const rc = outcome.receipt;
|
|
135
|
+
const report = formatThreeOrthogonalReport(rc);
|
|
136
|
+
const notifyType =
|
|
137
|
+
rc.summary === 'Completed' || rc.summary === 'Completed with diagnostics'
|
|
138
|
+
? 'info'
|
|
139
|
+
: rc.summary === 'Declined' || rc.summary === 'Rejected as Stale'
|
|
140
|
+
? 'warning'
|
|
141
|
+
: 'error';
|
|
142
|
+
ctx.ui.notify(report, notifyType);
|
|
143
|
+
}
|