@chatcode/cco-market 1.45.1
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 +146 -0
- package/README.zh.md +143 -0
- package/UPDATE-API-V1.md +132 -0
- package/client/client.js +11534 -0
- package/cordis.patch.yml +5 -0
- package/lib/accelerate.js +184 -0
- package/lib/agents.js +36 -0
- package/lib/backup.js +572 -0
- package/lib/catalog-local-match.js +126 -0
- package/lib/catalog-npm.js +111 -0
- package/lib/changelog.js +242 -0
- package/lib/channels.js +64 -0
- package/lib/check.js +1067 -0
- package/lib/compatibility.js +198 -0
- package/lib/diagnostics.js +49 -0
- package/lib/discovery-compatibility.js +260 -0
- package/lib/dsh-cli.js +949 -0
- package/lib/dsh-install.js +108 -0
- package/lib/gist.js +352 -0
- package/lib/groups.js +97 -0
- package/lib/home-paths.js +42 -0
- package/lib/hot.js +519 -0
- package/lib/http.js +39 -0
- package/lib/index.js +97 -0
- package/lib/install.js +387 -0
- package/lib/log.js +197 -0
- package/lib/ndjson.js +154 -0
- package/lib/net.js +98 -0
- package/lib/order.js +284 -0
- package/lib/patch.js +521 -0
- package/lib/pnpm-compat.js +490 -0
- package/lib/presets.js +278 -0
- package/lib/profile.js +911 -0
- package/lib/region-probe.js +93 -0
- package/lib/regions.js +253 -0
- package/lib/registry.js +204 -0
- package/lib/restart.js +373 -0
- package/lib/routes.js +4372 -0
- package/lib/settings.js +109 -0
- package/lib/skill-market/api.js +119 -0
- package/lib/skill-market/config.js +29 -0
- package/lib/skill-market/installer.js +352 -0
- package/lib/skill-market/runtime.js +13 -0
- package/lib/skill-market/tui.js +119 -0
- package/lib/skill-market/types.js +1 -0
- package/lib/skill-market/web-routes.js +123 -0
- package/lib/snapshot.js +500 -0
- package/lib/source-migration.js +54 -0
- package/lib/sources.js +596 -0
- package/lib/store.js +91 -0
- package/lib/themes.js +102 -0
- package/lib/trial.js +116 -0
- package/lib/types/accelerate.d.ts +85 -0
- package/lib/types/agents.d.ts +22 -0
- package/lib/types/backup.d.ts +131 -0
- package/lib/types/catalog-local-match.d.ts +50 -0
- package/lib/types/catalog-npm.d.ts +47 -0
- package/lib/types/changelog.d.ts +102 -0
- package/lib/types/channels.d.ts +56 -0
- package/lib/types/check.d.ts +266 -0
- package/lib/types/compatibility.d.ts +64 -0
- package/lib/types/diagnostics.d.ts +29 -0
- package/lib/types/discovery-compatibility.d.ts +80 -0
- package/lib/types/dsh-cli.d.ts +324 -0
- package/lib/types/dsh-install.d.ts +46 -0
- package/lib/types/gist.d.ts +53 -0
- package/lib/types/groups.d.ts +34 -0
- package/lib/types/home-paths.d.ts +16 -0
- package/lib/types/hot.d.ts +221 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/install.d.ts +172 -0
- package/lib/types/log.d.ts +43 -0
- package/lib/types/ndjson.d.ts +52 -0
- package/lib/types/net.d.ts +58 -0
- package/lib/types/order.d.ts +100 -0
- package/lib/types/patch.d.ts +129 -0
- package/lib/types/pnpm-compat.d.ts +93 -0
- package/lib/types/presets.d.ts +90 -0
- package/lib/types/profile.d.ts +253 -0
- package/lib/types/region-probe.d.ts +50 -0
- package/lib/types/regions.d.ts +122 -0
- package/lib/types/registry.d.ts +86 -0
- package/lib/types/restart.d.ts +194 -0
- package/lib/types/routes.d.ts +67 -0
- package/lib/types/settings.d.ts +78 -0
- package/lib/types/skill-market/api.d.ts +10 -0
- package/lib/types/skill-market/config.d.ts +3 -0
- package/lib/types/skill-market/installer.d.ts +23 -0
- package/lib/types/skill-market/runtime.d.ts +10 -0
- package/lib/types/skill-market/tui.d.ts +4 -0
- package/lib/types/skill-market/types.d.ts +71 -0
- package/lib/types/skill-market/web-routes.d.ts +19 -0
- package/lib/types/snapshot.d.ts +93 -0
- package/lib/types/source-migration.d.ts +11 -0
- package/lib/types/sources.d.ts +216 -0
- package/lib/types/store.d.ts +30 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/trial.d.ts +61 -0
- package/lib/types/update-api-v1.d.ts +66 -0
- package/lib/types/updates.d.ts +123 -0
- package/lib/types/verify.d.ts +139 -0
- package/lib/update-api-v1.js +215 -0
- package/lib/updates.js +361 -0
- package/lib/verify.js +453 -0
- package/package.json +121 -0
- package/src/accelerate.ts +213 -0
- package/src/agents.ts +43 -0
- package/src/backup.ts +583 -0
- package/src/catalog-local-match.ts +144 -0
- package/src/catalog-npm.ts +120 -0
- package/src/changelog.ts +282 -0
- package/src/channels.ts +70 -0
- package/src/check.ts +1239 -0
- package/src/client/CommentsModal.tsx +119 -0
- package/src/client/Diagnostics.tsx +907 -0
- package/src/client/ErrorBoundary.tsx +111 -0
- package/src/client/InstallToast.tsx +31 -0
- package/src/client/Market.module.css +830 -0
- package/src/client/MarketSection.tsx +5301 -0
- package/src/client/OperationsPanel.tsx +365 -0
- package/src/client/SettingsCard.tsx +621 -0
- package/src/client/SkillMarket.module.css +21 -0
- package/src/client/SkillMarketSection.tsx +162 -0
- package/src/client/comments.ts +54 -0
- package/src/client/globals.d.ts +13 -0
- package/src/client/index.ts +180 -0
- package/src/client/locales.ts +1112 -0
- package/src/client/market-data.ts +1321 -0
- package/src/client/operations.ts +201 -0
- package/src/client/preset-panel.tsx +263 -0
- package/src/client/primitives.d.ts +140 -0
- package/src/client/self-check.ts +147 -0
- package/src/client/snapshot-panel.tsx +244 -0
- package/src/compatibility.ts +237 -0
- package/src/diagnostics.ts +84 -0
- package/src/discovery-compatibility.ts +315 -0
- package/src/dsh-cli.ts +1126 -0
- package/src/dsh-install.ts +118 -0
- package/src/gist.ts +362 -0
- package/src/groups.ts +111 -0
- package/src/home-paths.ts +53 -0
- package/src/hot.ts +628 -0
- package/src/http.ts +41 -0
- package/src/index.ts +128 -0
- package/src/install.ts +420 -0
- package/src/log.ts +206 -0
- package/src/ndjson.ts +185 -0
- package/src/net.ts +106 -0
- package/src/order.ts +303 -0
- package/src/patch.ts +522 -0
- package/src/pnpm-compat.ts +527 -0
- package/src/presets.ts +344 -0
- package/src/profile.ts +940 -0
- package/src/region-probe.ts +97 -0
- package/src/regions.ts +310 -0
- package/src/registry.ts +250 -0
- package/src/restart.ts +396 -0
- package/src/routes.ts +4506 -0
- package/src/settings.ts +141 -0
- package/src/skill-market/api.ts +130 -0
- package/src/skill-market/config.ts +32 -0
- package/src/skill-market/installer.ts +337 -0
- package/src/skill-market/runtime.ts +14 -0
- package/src/skill-market/tui.ts +157 -0
- package/src/skill-market/types.ts +80 -0
- package/src/skill-market/web-routes.ts +132 -0
- package/src/snapshot.ts +532 -0
- package/src/source-migration.ts +61 -0
- package/src/sources.ts +565 -0
- package/src/store.ts +89 -0
- package/src/themes.ts +125 -0
- package/src/trial.ts +156 -0
- package/src/update-api-v1.ts +277 -0
- package/src/updates.ts +400 -0
- package/src/verify.ts +492 -0
package/src/themes.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme lifecycle: classifying installed packages as themes (by the
|
|
3
|
+
* registry's theme category), live-toggling bundle-layer entries through
|
|
4
|
+
* the loader, and keeping exactly one theme active with the choice
|
|
5
|
+
* persisted across restarts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { loadRegistry, pluginCategories } from './registry.ts'
|
|
9
|
+
import { hotMount, hotUnmount, listHotMounts, writeDisabled } from './hot.ts'
|
|
10
|
+
import { logEvent } from './log.ts'
|
|
11
|
+
import { profileDir, readInstalled } from './profile.ts'
|
|
12
|
+
import { repoOf } from './sources.ts'
|
|
13
|
+
|
|
14
|
+
/** The slice of a cordis loader entry the market needs for live enable/disable. */
|
|
15
|
+
export interface LoaderEntry {
|
|
16
|
+
options: { id?: string; name?: string; disabled?: boolean | null }
|
|
17
|
+
fiber?: unknown
|
|
18
|
+
update(options: { disabled: boolean | null }, create?: boolean, force?: boolean): Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The host surface the theme manager needs (loader entries + hot-mount context). */
|
|
22
|
+
export interface ThemeHost {
|
|
23
|
+
loader: { entries(): Iterable<LoaderEntry> }
|
|
24
|
+
plugin(plugin: unknown, config: unknown): { await(): Promise<unknown>; dispose(): Promise<unknown> | void }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Manages theme exclusivity for one profile. */
|
|
28
|
+
export interface ThemeManager {
|
|
29
|
+
installedThemeNames(): Promise<Set<string>>
|
|
30
|
+
setEntryDisabled(name: string, disabledFlag: boolean): Promise<boolean>
|
|
31
|
+
activateTheme(name: string): Promise<boolean>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create the theme manager. `disabledThemes` is the live, shared set of
|
|
36
|
+
* themes the user switched off — the caller owns reading it at boot and
|
|
37
|
+
* replaying it; the manager mutates and persists it on switches.
|
|
38
|
+
*/
|
|
39
|
+
export function createThemeManager(
|
|
40
|
+
host: ThemeHost,
|
|
41
|
+
profile: string,
|
|
42
|
+
disabledThemes: Set<string>,
|
|
43
|
+
explicitDir?: string,
|
|
44
|
+
): ThemeManager {
|
|
45
|
+
const activeProfileDir = profileDir(profile, explicitDir)
|
|
46
|
+
/** Installed package names classified as themes by the registry's theme category. */
|
|
47
|
+
async function installedThemeNames(): Promise<Set<string>> {
|
|
48
|
+
const names = new Set<string>()
|
|
49
|
+
try {
|
|
50
|
+
const registry = await loadRegistry()
|
|
51
|
+
const themeEntries = registry.plugins.filter(p => pluginCategories(p).includes('theme'))
|
|
52
|
+
const themeNames = new Set(themeEntries.map(p => p.name))
|
|
53
|
+
const themeRepos = new Set(
|
|
54
|
+
themeEntries.map(p => repoOf(p.url)).filter((r): r is string => r !== null).map(r => r.toLowerCase()),
|
|
55
|
+
)
|
|
56
|
+
for (const [name, spec] of Object.entries(readInstalled(profile, activeProfileDir))) {
|
|
57
|
+
if (themeNames.has(name)) {
|
|
58
|
+
names.add(name)
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
const match = /github:([^#\s]+)/.exec(String(spec).toLowerCase())
|
|
62
|
+
if (match !== null && themeRepos.has(match[1])) names.add(name)
|
|
63
|
+
}
|
|
64
|
+
} catch { /* registry unavailable — nothing classifies as a theme */ }
|
|
65
|
+
return names
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Live-toggle a bundle-layer plugin through its loader entry. Bundle trees
|
|
70
|
+
* are in-memory (write is a no-op), so this never touches any file — the
|
|
71
|
+
* market persists the choice itself and replays it at boot.
|
|
72
|
+
* @returns true when a matching live entry was found and updated.
|
|
73
|
+
*/
|
|
74
|
+
async function setEntryDisabled(name: string, disabledFlag: boolean): Promise<boolean> {
|
|
75
|
+
let found = false
|
|
76
|
+
for (const entry of host.loader.entries()) {
|
|
77
|
+
if (entry.options.name !== name) continue
|
|
78
|
+
// A disable can land while the entry's init is still in flight: the
|
|
79
|
+
// options flip but the finishing init brings the fiber up anyway, and a
|
|
80
|
+
// plain re-update no-ops on the empty diff. Force the update and verify
|
|
81
|
+
// the live state, retrying until reality matches the flag.
|
|
82
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
83
|
+
try {
|
|
84
|
+
await entry.update({ disabled: disabledFlag ? true : null }, false, true)
|
|
85
|
+
found = true
|
|
86
|
+
} catch (error) {
|
|
87
|
+
logEvent('warn', 'toggle', `${name}: entry update failed — ${error instanceof Error ? error.message : String(error)}`)
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
const live = entry.fiber !== undefined
|
|
91
|
+
if (live !== disabledFlag) break
|
|
92
|
+
await new Promise(resolvePromise => setTimeout(resolvePromise, 200))
|
|
93
|
+
}
|
|
94
|
+
logEvent('info', 'toggle',
|
|
95
|
+
`${name} -> ${disabledFlag ? 'off' : 'on'}: fiber=${String(entry.fiber !== undefined)}`)
|
|
96
|
+
}
|
|
97
|
+
if (!found) logEvent('info', 'toggle', `${name}: no loader entry matched`)
|
|
98
|
+
return found
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Make `name` the one active theme: deactivate every other installed theme
|
|
103
|
+
* (market hot mounts unmount; bundle-layer entries live-disable) and bring
|
|
104
|
+
* it up. The choice persists in state.json and is replayed at boot.
|
|
105
|
+
*/
|
|
106
|
+
async function activateTheme(name: string): Promise<boolean> {
|
|
107
|
+
const themes = await installedThemeNames()
|
|
108
|
+
for (const other of themes) {
|
|
109
|
+
if (other === name) continue
|
|
110
|
+
if (listHotMounts().includes(other)) {
|
|
111
|
+
await hotUnmount(other)
|
|
112
|
+
disabledThemes.add(other)
|
|
113
|
+
} else if (await setEntryDisabled(other, true)) {
|
|
114
|
+
disabledThemes.add(other)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
disabledThemes.delete(name)
|
|
118
|
+
writeDisabled(activeProfileDir, disabledThemes)
|
|
119
|
+
if (listHotMounts().includes(name)) return true
|
|
120
|
+
if (await setEntryDisabled(name, false)) return true
|
|
121
|
+
return (await hotMount(host, activeProfileDir, name)).ok
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { installedThemeNames, setEntryDisabled, activateTheme }
|
|
125
|
+
}
|
package/src/trial.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trial validation for composition changes — issue #98 (phase 3), the
|
|
3
|
+
* "trial boot" half of #19 reduced to what is safe and offline: before any
|
|
4
|
+
* bundle-order or preset change is written to the profile, replay the
|
|
5
|
+
* composition with the CANDIDATE order using the same entry-list machinery
|
|
6
|
+
* the real boot uses (src/check.ts's buildBundleLayers + composeLayers), and
|
|
7
|
+
* refuse the change when the composed tree would fail to boot (duplicate
|
|
8
|
+
* loader entry ids, unresolvable bundle layers, unparseable patches).
|
|
9
|
+
*
|
|
10
|
+
* No process, no network, no writes to the profile: the real profile is only
|
|
11
|
+
* read; if the candidate is bad, the failure is reported and nothing is
|
|
12
|
+
* applied (the caller then skips the write-back entirely).
|
|
13
|
+
*
|
|
14
|
+
* Issue #125 review: the CURRENT composition is replayed alongside the
|
|
15
|
+
* candidate, so the response also carries a current-vs-candidate diff
|
|
16
|
+
* (overrides / orphans / duplicates the reorder introduces) — not just
|
|
17
|
+
* whether it boots.
|
|
18
|
+
*
|
|
19
|
+
* Bundle resolution is deliberately SHARED with the check report
|
|
20
|
+
* (check.ts's buildBundleLayers): the dsh installation anchor first, then
|
|
21
|
+
* Node's module search from the profile (workspace-root hoisting) — so the
|
|
22
|
+
* trial can never disagree with the diagnostics about what a bundle is or
|
|
23
|
+
* where it lives, and official bundles resolve even when they are only
|
|
24
|
+
* hoisted to the workspace root.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
import {
|
|
30
|
+
buildBundleLayers, composeLayers, findDshInstallDir, parsePatchFile,
|
|
31
|
+
type DuplicateId, type LayerInput, type OrphanRow, type OverrideRow,
|
|
32
|
+
} from './check.ts'
|
|
33
|
+
import { resolveDshHome } from './home-paths.ts'
|
|
34
|
+
import { mergeOrder, readBundleStack } from './order.ts'
|
|
35
|
+
|
|
36
|
+
export interface TrialIssue {
|
|
37
|
+
layer: string
|
|
38
|
+
message: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Current-vs-candidate composition diff (issue #125 review). */
|
|
42
|
+
export interface TrialDiff {
|
|
43
|
+
/** Override relationships introduced by the candidate (not present in the current composition). */
|
|
44
|
+
overrides: OverrideRow[]
|
|
45
|
+
/** Orphan rows introduced by the candidate. */
|
|
46
|
+
orphans: OrphanRow[]
|
|
47
|
+
/** Duplicate loader entry ids introduced by the candidate. */
|
|
48
|
+
duplicates: DuplicateId[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TrialResult {
|
|
52
|
+
ok: boolean
|
|
53
|
+
errors: TrialIssue[]
|
|
54
|
+
warnings: TrialIssue[]
|
|
55
|
+
duplicates: DuplicateId[]
|
|
56
|
+
/** The composed loader rows under the candidate order. */
|
|
57
|
+
rows: { id: string; layer: string }[]
|
|
58
|
+
/** What the reorder would change vs the current composition (issue #125 review). */
|
|
59
|
+
diff: TrialDiff
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Replay the profile composition with `newCommunityOrder` (the candidate
|
|
64
|
+
* community-bundle order; official bundles keep their exact positions) and
|
|
65
|
+
* report anything that would break the boot. Pure read.
|
|
66
|
+
*/
|
|
67
|
+
export function trialValidate(
|
|
68
|
+
profileDir: string,
|
|
69
|
+
newCommunityOrder: string[],
|
|
70
|
+
options: { dshInstallDir?: string | null; homeDir?: string } = {},
|
|
71
|
+
): TrialResult {
|
|
72
|
+
const errors: TrialIssue[] = []
|
|
73
|
+
const warnings: TrialIssue[] = []
|
|
74
|
+
const current = readBundleStack(profileDir)
|
|
75
|
+
// Same merge the apply path uses (order.ts mergeOrder): the trial replays
|
|
76
|
+
// EXACTLY the stack a successful apply would write.
|
|
77
|
+
const merged = mergeOrder(current.bundles, newCommunityOrder)
|
|
78
|
+
if (!merged.ok) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false, errors: [{ layer: '(order)', message: merged.error }], warnings,
|
|
81
|
+
duplicates: [], rows: [],
|
|
82
|
+
diff: { overrides: [], orphans: [], duplicates: [] },
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const candidate = merged.bundles
|
|
86
|
+
|
|
87
|
+
// Shared bundle-layer build — identical resolution to analyzeProfile
|
|
88
|
+
// (dsh install anchor first, then Node module search from the profile).
|
|
89
|
+
let specs: Record<string, string> = {}
|
|
90
|
+
try {
|
|
91
|
+
const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as {
|
|
92
|
+
dependencies?: Record<string, string>
|
|
93
|
+
}
|
|
94
|
+
specs = manifest.dependencies ?? {}
|
|
95
|
+
} catch { /* specs stay empty — sources read as '(not a direct dependency)' */ }
|
|
96
|
+
const dshInstall = options.dshInstallDir !== undefined ? options.dshInstallDir : findDshInstallDir()
|
|
97
|
+
|
|
98
|
+
// Same user/home patch layers as the boot (and the check report) — shared
|
|
99
|
+
// by BOTH compositions.
|
|
100
|
+
const patchLayers: LayerInput[] = []
|
|
101
|
+
const userPatchPath = join(profileDir, 'cordis.patch.yml')
|
|
102
|
+
if (existsSync(userPatchPath)) {
|
|
103
|
+
const patches = parsePatchFile(userPatchPath)
|
|
104
|
+
patchLayers.push({ label: 'user-patch', kind: 'user', patches: patches ?? [], parseError: null })
|
|
105
|
+
if (patches === null) errors.push({ layer: 'user-patch', message: 'cordis.patch.yml is not a valid entry list / cordis.patch.yml 不是合法的条目列表' })
|
|
106
|
+
}
|
|
107
|
+
const home = resolveDshHome(options.homeDir)
|
|
108
|
+
const homePatchPath = join(home, 'cordis.patch.yml')
|
|
109
|
+
if (existsSync(homePatchPath)) {
|
|
110
|
+
const patches = parsePatchFile(homePatchPath)
|
|
111
|
+
patchLayers.push({ label: 'home-patch', kind: 'home', patches: patches ?? [], parseError: null })
|
|
112
|
+
if (patches === null) errors.push({ layer: 'home-patch', message: 'home cordis.patch.yml is not a valid entry list / 全局 cordis.patch.yml 不是合法的条目列表' })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Compose the loader tree for one bundle order (current or candidate). */
|
|
116
|
+
const compose = (bundleOrder: string[]): { built: ReturnType<typeof buildBundleLayers>; composed: ReturnType<typeof composeLayers> } => {
|
|
117
|
+
const built = buildBundleLayers(profileDir, bundleOrder, specs, dshInstall)
|
|
118
|
+
return { built, composed: composeLayers([...built.layers, ...patchLayers]) }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const currentState = compose(current.bundles)
|
|
122
|
+
const candidateState = compose(candidate)
|
|
123
|
+
const composed = candidateState.composed
|
|
124
|
+
|
|
125
|
+
for (const bundle of candidateState.built.bundles) {
|
|
126
|
+
if (bundle.error !== null) errors.push({ layer: bundle.name, message: bundle.error })
|
|
127
|
+
if (bundle.parseError !== null) errors.push({ layer: bundle.name, message: bundle.parseError })
|
|
128
|
+
}
|
|
129
|
+
for (const dup of composed.duplicates) {
|
|
130
|
+
errors.push({ layer: dup.layers.join(' / '), message: `duplicate loader entry id ${JSON.stringify(dup.id)} (${dup.count} rows) / 重复的 loader 条目 id ${JSON.stringify(dup.id)}` })
|
|
131
|
+
}
|
|
132
|
+
for (const orphan of composed.orphans) {
|
|
133
|
+
warnings.push({ layer: orphan.layer, message: `${orphan.id}: ${orphan.reason}` })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Issue #125 review: what does the reorder CHANGE? Compare the candidate
|
|
137
|
+
// composition against the current one — only candidate-introduced
|
|
138
|
+
// overrides / orphans / duplicates are reported.
|
|
139
|
+
const currentDupIds = new Set(currentState.composed.duplicates.map(d => d.id))
|
|
140
|
+
const sameOverride = (a: OverrideRow, b: OverrideRow): boolean =>
|
|
141
|
+
a.id === b.id && a.layer === b.layer && a.overriddenLayers.join('\u0000') === b.overriddenLayers.join('\u0000')
|
|
142
|
+
const diff: TrialDiff = {
|
|
143
|
+
overrides: composed.overrides.filter(o => !currentState.composed.overrides.some(c => sameOverride(o, c))),
|
|
144
|
+
orphans: composed.orphans.filter(o => !currentState.composed.orphans.some(c => c.id === o.id && c.layer === o.layer)),
|
|
145
|
+
duplicates: composed.duplicates.filter(d => !currentDupIds.has(d.id)),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
ok: errors.length === 0,
|
|
150
|
+
errors,
|
|
151
|
+
warnings,
|
|
152
|
+
duplicates: composed.duplicates,
|
|
153
|
+
rows: composed.rows.map(row => ({ id: row.id, layer: row.layer })),
|
|
154
|
+
diff,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable, versioned contract for plugin-owned update surfaces.
|
|
3
|
+
*
|
|
4
|
+
* The market UI has richer internal response shapes that evolve with its UI.
|
|
5
|
+
* Third-party plugins must not depend on those shapes, so this module owns the
|
|
6
|
+
* small JSON envelope exposed under `/dsh-market/api/v1`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { InstallProgress } from './dsh-cli.ts'
|
|
10
|
+
|
|
11
|
+
export const UPDATE_API_V1_SCHEMA = 'dsh-market/update-api/v1' as const
|
|
12
|
+
export const MAX_UPDATE_OPERATIONS_V1 = 50
|
|
13
|
+
|
|
14
|
+
export type UpdateOperationState =
|
|
15
|
+
| 'queued'
|
|
16
|
+
| 'running'
|
|
17
|
+
| 'succeeded'
|
|
18
|
+
| 'failed'
|
|
19
|
+
| 'cancelled'
|
|
20
|
+
| 'rolled-back'
|
|
21
|
+
|
|
22
|
+
export interface UpdateFailureV1 {
|
|
23
|
+
code: string
|
|
24
|
+
message: string
|
|
25
|
+
retryable: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UpdateOperationV1 {
|
|
29
|
+
schema: typeof UPDATE_API_V1_SCHEMA
|
|
30
|
+
operationId: string
|
|
31
|
+
kind: 'update'
|
|
32
|
+
packageName: string
|
|
33
|
+
state: UpdateOperationState
|
|
34
|
+
createdAt: number
|
|
35
|
+
startedAt: number | null
|
|
36
|
+
finishedAt: number | null
|
|
37
|
+
beforeVersion: string | null
|
|
38
|
+
installedVersion: string | null
|
|
39
|
+
progress: {
|
|
40
|
+
phase: string | null
|
|
41
|
+
done: number
|
|
42
|
+
total: number | null
|
|
43
|
+
percent: number | null
|
|
44
|
+
currentPackage: string | null
|
|
45
|
+
detail: string | null
|
|
46
|
+
downloaded: number | null
|
|
47
|
+
size: number | null
|
|
48
|
+
}
|
|
49
|
+
outcome: {
|
|
50
|
+
refreshRequired: boolean
|
|
51
|
+
restartRequired: boolean
|
|
52
|
+
rollback: {
|
|
53
|
+
available: boolean
|
|
54
|
+
state: 'unavailable' | 'available' | 'running' | 'succeeded' | 'failed'
|
|
55
|
+
detail: string | null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
failure: UpdateFailureV1 | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface StoredOperation extends UpdateOperationV1 {
|
|
62
|
+
legacyRollbackId: string | null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
66
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
67
|
+
? value as Record<string, unknown>
|
|
68
|
+
: null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function text(value: unknown): string | null {
|
|
72
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function bool(value: unknown): boolean {
|
|
76
|
+
return value === true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function failureCode(status: number, body: Record<string, unknown>): string {
|
|
80
|
+
const explicit = text(body.failureCode)
|
|
81
|
+
if (explicit === 'DOWNGRADE_DETECTED' || explicit === 'RESOLVED_VERSION_MISMATCH') return explicit
|
|
82
|
+
if (body.agentsBusy === true) return 'AGENTS_RUNNING'
|
|
83
|
+
if (body.busy === true || status === 409) return 'OPERATION_BUSY'
|
|
84
|
+
if (body.stale === true && body.staleReason === 'release-age') return 'RELEASE_TOO_FRESH'
|
|
85
|
+
if (body.stale === true) return 'VERSION_UNCHANGED'
|
|
86
|
+
if (body.timedOut === true) return 'UPDATE_TIMEOUT'
|
|
87
|
+
if (status === 403) return 'UPDATE_FORBIDDEN'
|
|
88
|
+
if (status === 400) return 'UPDATE_REJECTED'
|
|
89
|
+
return 'UPDATE_FAILED'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function failureOf(status: number, body: Record<string, unknown>): UpdateFailureV1 {
|
|
93
|
+
const code = failureCode(status, body)
|
|
94
|
+
const message = text(body.error)
|
|
95
|
+
?? text(body.stderr)
|
|
96
|
+
?? text(body.stdout)
|
|
97
|
+
?? `update failed with HTTP ${String(status)}`
|
|
98
|
+
return {
|
|
99
|
+
code,
|
|
100
|
+
message: message.slice(-1200),
|
|
101
|
+
retryable: code === 'AGENTS_RUNNING'
|
|
102
|
+
|| code === 'OPERATION_BUSY'
|
|
103
|
+
|| code === 'RELEASE_TOO_FRESH'
|
|
104
|
+
|| code === 'VERSION_UNCHANGED'
|
|
105
|
+
|| code === 'RESOLVED_VERSION_MISMATCH'
|
|
106
|
+
|| code === 'UPDATE_TIMEOUT',
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function activationState(body: Record<string, unknown>, packageName: string): string | null {
|
|
111
|
+
const activations = record(body.activation)
|
|
112
|
+
const activation = activations === null ? null : record(activations[packageName])
|
|
113
|
+
return activation === null ? null : text(activation.state)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function rollbackIdOf(body: Record<string, unknown>): string | null {
|
|
117
|
+
const compatibility = record(body.compatibility)
|
|
118
|
+
return compatibility === null ? null : text(compatibility.rollbackId)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function progressView(progress?: InstallProgress): UpdateOperationV1['progress'] {
|
|
122
|
+
if (progress === undefined) {
|
|
123
|
+
return {
|
|
124
|
+
phase: null,
|
|
125
|
+
done: 0,
|
|
126
|
+
total: null,
|
|
127
|
+
percent: null,
|
|
128
|
+
currentPackage: null,
|
|
129
|
+
detail: null,
|
|
130
|
+
downloaded: null,
|
|
131
|
+
size: null,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const percent = progress.total !== null && progress.total > 0
|
|
135
|
+
? Math.min(100, Math.round(progress.done / progress.total * 100))
|
|
136
|
+
: null
|
|
137
|
+
return {
|
|
138
|
+
phase: progress.phase,
|
|
139
|
+
done: progress.done,
|
|
140
|
+
total: progress.total,
|
|
141
|
+
percent,
|
|
142
|
+
currentPackage: progress.currentPackage,
|
|
143
|
+
detail: text(progress.lastLine) ?? text(progress.error),
|
|
144
|
+
downloaded: progress.downloaded,
|
|
145
|
+
size: progress.size,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Process-local operation registry. A boot id scopes ids across restarts. */
|
|
150
|
+
export class UpdateOperationStoreV1 {
|
|
151
|
+
private sequence = 0
|
|
152
|
+
private readonly operations = new Map<string, StoredOperation>()
|
|
153
|
+
private activeId: string | null = null
|
|
154
|
+
|
|
155
|
+
constructor(
|
|
156
|
+
private readonly bootId: string,
|
|
157
|
+
private readonly now: () => number = Date.now,
|
|
158
|
+
private readonly maxOperations: number = MAX_UPDATE_OPERATIONS_V1,
|
|
159
|
+
) {}
|
|
160
|
+
|
|
161
|
+
hasActive(): boolean {
|
|
162
|
+
return this.activeId !== null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
create(packageName: string, beforeVersion: string | null): UpdateOperationV1 {
|
|
166
|
+
const operationId = `${this.bootId}-update-${String(++this.sequence)}`
|
|
167
|
+
const createdAt = this.now()
|
|
168
|
+
const operation: StoredOperation = {
|
|
169
|
+
schema: UPDATE_API_V1_SCHEMA,
|
|
170
|
+
operationId,
|
|
171
|
+
kind: 'update',
|
|
172
|
+
packageName,
|
|
173
|
+
state: 'queued',
|
|
174
|
+
createdAt,
|
|
175
|
+
startedAt: null,
|
|
176
|
+
finishedAt: null,
|
|
177
|
+
beforeVersion,
|
|
178
|
+
installedVersion: beforeVersion,
|
|
179
|
+
progress: progressView(),
|
|
180
|
+
outcome: {
|
|
181
|
+
refreshRequired: false,
|
|
182
|
+
restartRequired: false,
|
|
183
|
+
rollback: { available: false, state: 'unavailable', detail: null },
|
|
184
|
+
},
|
|
185
|
+
failure: null,
|
|
186
|
+
legacyRollbackId: null,
|
|
187
|
+
}
|
|
188
|
+
this.operations.set(operationId, operation)
|
|
189
|
+
this.activeId = operationId
|
|
190
|
+
while (this.operations.size > this.maxOperations) {
|
|
191
|
+
const oldestId = this.operations.keys().next().value as string | undefined
|
|
192
|
+
if (oldestId === undefined || oldestId === this.activeId) break
|
|
193
|
+
this.operations.delete(oldestId)
|
|
194
|
+
}
|
|
195
|
+
return this.snapshot(operation)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
start(operationId: string): void {
|
|
199
|
+
const operation = this.operations.get(operationId)
|
|
200
|
+
if (operation === undefined) return
|
|
201
|
+
operation.state = 'running'
|
|
202
|
+
operation.startedAt = this.now()
|
|
203
|
+
this.activeId = operationId
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
finish(
|
|
207
|
+
operationId: string,
|
|
208
|
+
status: number,
|
|
209
|
+
payload: unknown,
|
|
210
|
+
installedVersion: string | null,
|
|
211
|
+
): UpdateOperationV1 | null {
|
|
212
|
+
const operation = this.operations.get(operationId)
|
|
213
|
+
if (operation === undefined) return null
|
|
214
|
+
const body = record(payload) ?? {}
|
|
215
|
+
const cancelled = bool(body.cancelled)
|
|
216
|
+
const ok = status >= 200 && status < 300 && bool(body.ok) && !cancelled
|
|
217
|
+
operation.state = cancelled ? 'cancelled' : ok ? 'succeeded' : 'failed'
|
|
218
|
+
operation.finishedAt = this.now()
|
|
219
|
+
operation.installedVersion = installedVersion
|
|
220
|
+
operation.failure = ok || cancelled ? null : failureOf(status, body)
|
|
221
|
+
const activation = activationState(body, operation.packageName)
|
|
222
|
+
operation.outcome.restartRequired = activation === 'restart'
|
|
223
|
+
operation.outcome.refreshRequired = bool(body.needsRefresh)
|
|
224
|
+
|| (activation === 'live' && body.hot === true)
|
|
225
|
+
operation.legacyRollbackId = rollbackIdOf(body)
|
|
226
|
+
if (operation.legacyRollbackId !== null) {
|
|
227
|
+
operation.outcome.rollback.available = true
|
|
228
|
+
operation.outcome.rollback.state = 'available'
|
|
229
|
+
}
|
|
230
|
+
if (this.activeId === operationId) this.activeId = null
|
|
231
|
+
return this.snapshot(operation)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
beginRollback(operationId: string): string | null {
|
|
235
|
+
const operation = this.operations.get(operationId)
|
|
236
|
+
if (operation?.legacyRollbackId === null || operation?.legacyRollbackId === undefined) return null
|
|
237
|
+
operation.outcome.rollback.state = 'running'
|
|
238
|
+
operation.outcome.rollback.detail = null
|
|
239
|
+
return operation.legacyRollbackId
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
finishRollback(
|
|
243
|
+
operationId: string,
|
|
244
|
+
status: number,
|
|
245
|
+
payload: unknown,
|
|
246
|
+
installedVersion?: string | null,
|
|
247
|
+
): UpdateOperationV1 | null {
|
|
248
|
+
const operation = this.operations.get(operationId)
|
|
249
|
+
if (operation === undefined) return null
|
|
250
|
+
const body = record(payload) ?? {}
|
|
251
|
+
const ok = status >= 200 && status < 300 && body.rolledBack === true
|
|
252
|
+
// The rollback endpoint supplies a fresh disk read. Keep the argument
|
|
253
|
+
// optional so existing store consumers retain source compatibility.
|
|
254
|
+
if (installedVersion !== undefined) operation.installedVersion = installedVersion
|
|
255
|
+
operation.outcome.rollback.available = !ok && status !== 400
|
|
256
|
+
operation.outcome.rollback.state = ok ? 'succeeded' : 'failed'
|
|
257
|
+
operation.outcome.rollback.detail = ok ? null : text(body.error) ?? `rollback failed with HTTP ${String(status)}`
|
|
258
|
+
if (ok) {
|
|
259
|
+
operation.state = 'rolled-back'
|
|
260
|
+
operation.outcome.restartRequired = true
|
|
261
|
+
}
|
|
262
|
+
return this.snapshot(operation)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
get(operationId: string, progress?: InstallProgress): UpdateOperationV1 | null {
|
|
266
|
+
const operation = this.operations.get(operationId)
|
|
267
|
+
if (operation === undefined) return null
|
|
268
|
+
const view = this.snapshot(operation)
|
|
269
|
+
if (this.activeId === operationId && operation.state === 'running') view.progress = progressView(progress)
|
|
270
|
+
return view
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private snapshot(operation: StoredOperation): UpdateOperationV1 {
|
|
274
|
+
const { legacyRollbackId: _legacyRollbackId, ...view } = operation
|
|
275
|
+
return structuredClone(view)
|
|
276
|
+
}
|
|
277
|
+
}
|