@adea-ai/plugins 1.0.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 +201 -0
- package/README.md +74 -0
- package/dist/agent-plugins-catalog.d.ts +26 -0
- package/dist/agent-plugins-catalog.js +699 -0
- package/dist/agent-plugins-catalog.js.map +1 -0
- package/dist/agent-plugins-harness.d.ts +54 -0
- package/dist/agent-plugins-harness.js +212 -0
- package/dist/agent-plugins-harness.js.map +1 -0
- package/dist/agent-plugins-schema.d.ts +402 -0
- package/dist/agent-plugins-schema.js +200 -0
- package/dist/agent-plugins-schema.js.map +1 -0
- package/dist/consumer-index.d.ts +217 -0
- package/dist/consumer-index.js +358 -0
- package/dist/consumer-index.js.map +1 -0
- package/dist/display-name.d.ts +8 -0
- package/dist/display-name.js +160 -0
- package/dist/display-name.js.map +1 -0
- package/dist/harness.d.ts +13 -0
- package/dist/harness.js +169 -0
- package/dist/harness.js.map +1 -0
- package/dist/icon-mirror.d.ts +69 -0
- package/dist/icon-mirror.js +147 -0
- package/dist/icon-mirror.js.map +1 -0
- package/dist/icons.d.ts +65 -0
- package/dist/icons.js +268 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +304 -0
- package/dist/index.js +1877 -0
- package/dist/index.js.map +1 -0
- package/dist/portable-catalog.d.ts +9 -0
- package/dist/portable-catalog.js +175 -0
- package/dist/portable-catalog.js.map +1 -0
- package/dist/publication.d.ts +98 -0
- package/dist/publication.js +121 -0
- package/dist/publication.js.map +1 -0
- package/dist/schema.d.ts +705 -0
- package/dist/schema.js +267 -0
- package/dist/schema.js.map +1 -0
- package/dist/site-icons.d.ts +55 -0
- package/dist/site-icons.js +155 -0
- package/dist/site-icons.js.map +1 -0
- package/dist/sources.d.ts +54 -0
- package/dist/sources.js +306 -0
- package/dist/sources.js.map +1 -0
- package/package.json +102 -0
- package/schemas/agent-package.v1.json +387 -0
- package/schemas/catalog.v1.schema.json +407 -0
- package/schemas/compatibility.v1.schema.json +23 -0
- package/schemas/harness-profile.v1.json +75 -0
- package/schemas/installation-plan.v2.json +695 -0
- package/schemas/sources-lock.v1.schema.json +61 -0
- package/src/agent-plugins-catalog.test.ts +402 -0
- package/src/agent-plugins-catalog.ts +800 -0
- package/src/agent-plugins-harness.test.ts +272 -0
- package/src/agent-plugins-harness.ts +281 -0
- package/src/agent-plugins-schema.test.ts +27 -0
- package/src/agent-plugins-schema.ts +219 -0
- package/src/artifacts.test.ts +545 -0
- package/src/catalog-installation.test.ts +42 -0
- package/src/consumer-index.test.ts +465 -0
- package/src/consumer-index.ts +592 -0
- package/src/display-name.test.ts +56 -0
- package/src/display-name.ts +157 -0
- package/src/harness.ts +202 -0
- package/src/icon-mirror.ts +196 -0
- package/src/icons.test.ts +185 -0
- package/src/icons.ts +297 -0
- package/src/index.test.ts +888 -0
- package/src/index.ts +2538 -0
- package/src/portable-catalog.test.ts +186 -0
- package/src/portable-catalog.ts +203 -0
- package/src/publication.test.ts +117 -0
- package/src/publication.ts +171 -0
- package/src/schema.ts +309 -0
- package/src/site-icon-fallback.test.ts +267 -0
- package/src/site-icons.test.ts +171 -0
- package/src/site-icons.ts +161 -0
- package/src/sources.ts +360 -0
- package/src/upstream-headers.test.ts +148 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-name normalization for catalog plugins.
|
|
3
|
+
*
|
|
4
|
+
* Marketplace manifests are inconsistent: a vendor names a plugin `Gmail`, the
|
|
5
|
+
* next one names it `zapier`, and a curated source names it
|
|
6
|
+
* `aws-agents-for-devsecops`. The UI renders those strings verbatim, so one
|
|
7
|
+
* list reads `Zapier` and the next reads `zapier` beside it. This turns a
|
|
8
|
+
* slug-shaped name into the title-cased form a person would write, in the
|
|
9
|
+
* catalog, so every consumer shows the same thing without its own guesswork.
|
|
10
|
+
*
|
|
11
|
+
* Two rules keep it from "correcting" names that are already right:
|
|
12
|
+
*
|
|
13
|
+
* - a token that carries any uppercase is left exactly as it is, which
|
|
14
|
+
* preserves vendor casing (`Gmail`, `iMessage`, `eToro`, `PDF Viewer`) and
|
|
15
|
+
* makes the function idempotent;
|
|
16
|
+
* - a token with a published non-title-case form comes from `BRAND_TOKENS`
|
|
17
|
+
* instead of the general rule, because title-casing `npm` → `Npm` or
|
|
18
|
+
* `github` → `Github` is exactly the inconsistency this exists to remove.
|
|
19
|
+
*/
|
|
20
|
+
const BRAND_TOKENS: Readonly<Record<string, string>> = {
|
|
21
|
+
'42crunch': '42Crunch',
|
|
22
|
+
adlc: 'ADLC',
|
|
23
|
+
ai: 'AI',
|
|
24
|
+
bigquery: 'BigQuery',
|
|
25
|
+
brightdata: 'Bright Data',
|
|
26
|
+
amd: 'AMD',
|
|
27
|
+
alloydb: 'AlloyDB',
|
|
28
|
+
api: 'API',
|
|
29
|
+
apis: 'APIs',
|
|
30
|
+
aws: 'AWS',
|
|
31
|
+
cds: 'CDS',
|
|
32
|
+
ckeditor: 'CKEditor',
|
|
33
|
+
clickhouse: 'ClickHouse',
|
|
34
|
+
cli: 'CLI',
|
|
35
|
+
coderabbit: 'CodeRabbit',
|
|
36
|
+
crowdsec: 'CrowdSec',
|
|
37
|
+
crm: 'CRM',
|
|
38
|
+
crowdstrike: 'CrowdStrike',
|
|
39
|
+
css: 'CSS',
|
|
40
|
+
dak: 'DAK',
|
|
41
|
+
dominodatalab: 'Domino Data Lab',
|
|
42
|
+
datadog: 'Datadog',
|
|
43
|
+
fullstory: 'FullStory',
|
|
44
|
+
gitkraken: 'GitKraken',
|
|
45
|
+
devops: 'DevOps',
|
|
46
|
+
devsecops: 'DevSecOps',
|
|
47
|
+
gcp: 'GCP',
|
|
48
|
+
github: 'GitHub',
|
|
49
|
+
gitlab: 'GitLab',
|
|
50
|
+
graphql: 'GraphQL',
|
|
51
|
+
html: 'HTML',
|
|
52
|
+
huggingface: 'Hugging Face',
|
|
53
|
+
ibm: 'IBM',
|
|
54
|
+
idmp: 'IDMP',
|
|
55
|
+
imessage: 'iMessage',
|
|
56
|
+
ios: 'iOS',
|
|
57
|
+
javascript: 'JavaScript',
|
|
58
|
+
json: 'JSON',
|
|
59
|
+
k8s: 'Kubernetes',
|
|
60
|
+
langchain: 'LangChain',
|
|
61
|
+
llm: 'LLM',
|
|
62
|
+
llms: 'LLMs',
|
|
63
|
+
logrocket: 'LogRocket',
|
|
64
|
+
lsp: 'LSP',
|
|
65
|
+
lseg: 'LSEG',
|
|
66
|
+
macos: 'macOS',
|
|
67
|
+
md: 'MD',
|
|
68
|
+
mcp: 'MCP',
|
|
69
|
+
mongodb: 'MongoDB',
|
|
70
|
+
oracledb: 'Oracle Database',
|
|
71
|
+
pagerduty: 'PagerDuty',
|
|
72
|
+
planetscale: 'PlanetScale',
|
|
73
|
+
posthog: 'PostHog',
|
|
74
|
+
mysql: 'MySQL',
|
|
75
|
+
ngs: 'NGS',
|
|
76
|
+
nosql: 'NoSQL',
|
|
77
|
+
nvidia: 'NVIDIA',
|
|
78
|
+
ocr: 'OCR',
|
|
79
|
+
openai: 'OpenAI',
|
|
80
|
+
paypal: 'PayPal',
|
|
81
|
+
pdf: 'PDF',
|
|
82
|
+
postgres: 'Postgres',
|
|
83
|
+
postgresql: 'PostgreSQL',
|
|
84
|
+
pytorch: 'PyTorch',
|
|
85
|
+
sap: 'SAP',
|
|
86
|
+
sdk: 'SDK',
|
|
87
|
+
seo: 'SEO',
|
|
88
|
+
servicenow: 'ServiceNow',
|
|
89
|
+
sql: 'SQL',
|
|
90
|
+
sre: 'SRE',
|
|
91
|
+
tensorflow: 'TensorFlow',
|
|
92
|
+
terraform: 'Terraform',
|
|
93
|
+
twg: 'TWG',
|
|
94
|
+
typescript: 'TypeScript',
|
|
95
|
+
ui: 'UI',
|
|
96
|
+
ui5: 'UI5',
|
|
97
|
+
ux: 'UX',
|
|
98
|
+
whatsapp: 'WhatsApp',
|
|
99
|
+
wordpress: 'WordPress',
|
|
100
|
+
youtube: 'YouTube',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Articles, conjunctions and prepositions stay lowercase inside a name. */
|
|
104
|
+
const SMALL_WORDS = new Set([
|
|
105
|
+
'a',
|
|
106
|
+
'an',
|
|
107
|
+
'and',
|
|
108
|
+
'as',
|
|
109
|
+
'at',
|
|
110
|
+
'by',
|
|
111
|
+
'for',
|
|
112
|
+
'from',
|
|
113
|
+
'in',
|
|
114
|
+
'of',
|
|
115
|
+
'on',
|
|
116
|
+
'or',
|
|
117
|
+
'the',
|
|
118
|
+
'to',
|
|
119
|
+
'with',
|
|
120
|
+
])
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Names whose slug form hides a domain or an initialism, which the token rules
|
|
124
|
+
* below cannot recover: `monday-com` is `monday.com`, not `Monday Com`.
|
|
125
|
+
*/
|
|
126
|
+
const FULL_NAMES: Readonly<Record<string, string>> = {
|
|
127
|
+
'bigdata-com': 'BigData.com',
|
|
128
|
+
'incident-io': 'Incident.io',
|
|
129
|
+
'monday-com': 'Monday.com',
|
|
130
|
+
vpai: 'VPAI',
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Title-cases a slug-shaped name and leaves a properly cased one alone.
|
|
135
|
+
*
|
|
136
|
+
* Separators become spaces (`adobe-for-creativity` → `Adobe for Creativity`),
|
|
137
|
+
* but a dot does not, so domain-style names keep their shape
|
|
138
|
+
* (`monday.com` → `Monday.com`).
|
|
139
|
+
*/
|
|
140
|
+
export function normalizeDisplayName(value: string): string {
|
|
141
|
+
const trimmed = value.trim()
|
|
142
|
+
const known = FULL_NAMES[trimmed.toLowerCase()]
|
|
143
|
+
if (known !== undefined) return known
|
|
144
|
+
const tokens = trimmed.split(/[-_\s]+/u).filter(Boolean)
|
|
145
|
+
if (tokens.length === 0) return value
|
|
146
|
+
return tokens
|
|
147
|
+
.map((token, index) => {
|
|
148
|
+
const key = token.toLowerCase()
|
|
149
|
+
const brand = BRAND_TOKENS[key]
|
|
150
|
+
if (brand !== undefined) return brand
|
|
151
|
+
// Any uppercase means the vendor already chose a casing.
|
|
152
|
+
if (/\p{Lu}/u.test(token)) return token
|
|
153
|
+
if (index > 0 && SMALL_WORDS.has(key)) return key
|
|
154
|
+
return `${token.slice(0, 1).toUpperCase()}${token.slice(1)}`
|
|
155
|
+
})
|
|
156
|
+
.join(' ')
|
|
157
|
+
}
|
package/src/harness.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HarnessSchema,
|
|
3
|
+
MaterializationPlanSchema,
|
|
4
|
+
type Catalog,
|
|
5
|
+
type Harness,
|
|
6
|
+
type MaterializationPlan,
|
|
7
|
+
type Plugin,
|
|
8
|
+
type PluginRelease,
|
|
9
|
+
} from './schema.js'
|
|
10
|
+
|
|
11
|
+
export class MaterializationError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
readonly code: string,
|
|
14
|
+
readonly details: readonly string[] = []
|
|
15
|
+
) {
|
|
16
|
+
super(`${code}${details.length ? `: ${details.join(', ')}` : ''}`)
|
|
17
|
+
this.name = 'MaterializationError'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createMaterializationPlan(input: {
|
|
22
|
+
readonly catalog: Catalog
|
|
23
|
+
readonly pluginId: string
|
|
24
|
+
readonly harness: Harness
|
|
25
|
+
readonly releaseId?: string
|
|
26
|
+
}): MaterializationPlan {
|
|
27
|
+
const plugin = input.catalog.plugins.find((candidate) => candidate.pluginId === input.pluginId)
|
|
28
|
+
if (!plugin) throw new MaterializationError('PLUGIN_NOT_FOUND', [input.pluginId])
|
|
29
|
+
const release = plugin.availableReleases.find(
|
|
30
|
+
(candidate) => candidate.releaseId === (input.releaseId ?? plugin.currentReleaseId)
|
|
31
|
+
)
|
|
32
|
+
if (!release)
|
|
33
|
+
throw new MaterializationError('RELEASE_NOT_FOUND', [
|
|
34
|
+
input.releaseId ?? plugin.currentReleaseId,
|
|
35
|
+
])
|
|
36
|
+
const compatibility = plugin.harnessCompatibility[input.harness]
|
|
37
|
+
if (!compatibility) throw new MaterializationError('HARNESS_UNSUPPORTED', [input.harness])
|
|
38
|
+
return MaterializationPlanSchema.parse({
|
|
39
|
+
planVersion: 1,
|
|
40
|
+
pluginId: plugin.pluginId,
|
|
41
|
+
releaseId: release.releaseId,
|
|
42
|
+
harness: input.harness,
|
|
43
|
+
source: {
|
|
44
|
+
repositoryUrl: release.resolvedRepositoryUrl,
|
|
45
|
+
commitSha: release.resolvedCommitSha,
|
|
46
|
+
pluginSubdirectory: release.pluginSubdirectory,
|
|
47
|
+
contentDigest: release.canonicalContentDigest,
|
|
48
|
+
},
|
|
49
|
+
targetLayout: targetLayout(input.harness),
|
|
50
|
+
files: release.fileIndex
|
|
51
|
+
.map((sourcePath) => filePlan(release, sourcePath, input.harness, compatibility.status))
|
|
52
|
+
.toSorted(
|
|
53
|
+
(left, right) =>
|
|
54
|
+
left.targetPath.localeCompare(right.targetPath) ||
|
|
55
|
+
left.sourcePath.localeCompare(right.sourcePath)
|
|
56
|
+
),
|
|
57
|
+
configuration: {
|
|
58
|
+
compatibilityStatus: compatibility.status,
|
|
59
|
+
sourceDialect: release.releaseMetadata.sourceDialect ?? 'unknown',
|
|
60
|
+
executeInstallation: false,
|
|
61
|
+
},
|
|
62
|
+
requiredConnectors: release.requiredConnectors,
|
|
63
|
+
requiredCredentials: release.requiredCredentials,
|
|
64
|
+
policyConstraints: [
|
|
65
|
+
'This plan describes files and translation only; it does not install or execute upstream code.',
|
|
66
|
+
...release.permissionSensitiveChanges.map(
|
|
67
|
+
(type) => `Control Plane must authorize ${type} capability explicitly.`
|
|
68
|
+
),
|
|
69
|
+
],
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function targetLayout(harness: Harness): { root: string; notes: string[] } {
|
|
74
|
+
switch (harness) {
|
|
75
|
+
case 'codex':
|
|
76
|
+
return {
|
|
77
|
+
root: '.agents',
|
|
78
|
+
notes: [
|
|
79
|
+
'Skills and instructions map to the Codex .agents layout.',
|
|
80
|
+
'MCP and executable capabilities require Control Plane policy.',
|
|
81
|
+
],
|
|
82
|
+
}
|
|
83
|
+
case 'claude-code':
|
|
84
|
+
return {
|
|
85
|
+
root: '.claude',
|
|
86
|
+
notes: [
|
|
87
|
+
'Claude Code-native files can be copied when the source dialect is Claude.',
|
|
88
|
+
'Hooks and MCP configuration still require explicit runtime authorization.',
|
|
89
|
+
],
|
|
90
|
+
}
|
|
91
|
+
case 'cursor':
|
|
92
|
+
return {
|
|
93
|
+
root: '.cursor',
|
|
94
|
+
notes: [
|
|
95
|
+
'Cursor rules and skills map to the .cursor layout.',
|
|
96
|
+
'MCP configuration is declarative and is not executed by this plan.',
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
case 'pi':
|
|
100
|
+
return {
|
|
101
|
+
root: '.pi',
|
|
102
|
+
notes: [
|
|
103
|
+
'Pi receives portable SKILL.md and MCP metadata only.',
|
|
104
|
+
'No Pi process or package lifecycle is invoked.',
|
|
105
|
+
],
|
|
106
|
+
}
|
|
107
|
+
case 'hermes':
|
|
108
|
+
return {
|
|
109
|
+
root: '.hermes',
|
|
110
|
+
notes: [
|
|
111
|
+
'Hermes receives portable skill and MCP metadata only.',
|
|
112
|
+
'Harness-specific translation remains explicit in each file action.',
|
|
113
|
+
],
|
|
114
|
+
}
|
|
115
|
+
case 'opencode':
|
|
116
|
+
return {
|
|
117
|
+
root: '.opencode',
|
|
118
|
+
notes: [
|
|
119
|
+
'OpenCode receives portable instruction and MCP metadata only.',
|
|
120
|
+
'Executable capabilities are excluded.',
|
|
121
|
+
],
|
|
122
|
+
}
|
|
123
|
+
case 'generic-skill-mcp':
|
|
124
|
+
return {
|
|
125
|
+
root: '.agent',
|
|
126
|
+
notes: ['Generic SKILL.md and MCP-compatible files are copied when recognized.'],
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function filePlan(
|
|
132
|
+
release: PluginRelease,
|
|
133
|
+
sourcePath: string,
|
|
134
|
+
harness: Harness,
|
|
135
|
+
status: Plugin['harnessCompatibility'][Harness]['status']
|
|
136
|
+
) {
|
|
137
|
+
const type = capabilityTypeForPath(release, sourcePath)
|
|
138
|
+
const directory = sourcePath.includes('/') ? sourcePath.slice(0, sourcePath.lastIndexOf('/')) : ''
|
|
139
|
+
if (status === 'unsupported' || type === 'executable' || type === 'unknown') {
|
|
140
|
+
return {
|
|
141
|
+
sourcePath,
|
|
142
|
+
targetPath: sourcePath,
|
|
143
|
+
action: 'unsupported' as const,
|
|
144
|
+
reason: `${type} capability is not executable through a materialization plan.`,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
type === 'hook' ||
|
|
149
|
+
type === 'browser' ||
|
|
150
|
+
type === 'scheduled-task' ||
|
|
151
|
+
type === 'ui-component'
|
|
152
|
+
) {
|
|
153
|
+
return {
|
|
154
|
+
sourcePath,
|
|
155
|
+
targetPath: sourcePath,
|
|
156
|
+
action: 'ignore' as const,
|
|
157
|
+
reason: `${type} is cataloged but has no portable target layout for ${harness}.`,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const targetPath = targetForType(type, sourcePath, directory, harness)
|
|
161
|
+
const action =
|
|
162
|
+
type === 'skill' || type === 'rule' || (type === 'mcp-server' && status === 'native')
|
|
163
|
+
? 'copy'
|
|
164
|
+
: 'translate'
|
|
165
|
+
return {
|
|
166
|
+
sourcePath,
|
|
167
|
+
targetPath,
|
|
168
|
+
action: action as 'copy' | 'translate',
|
|
169
|
+
reason:
|
|
170
|
+
action === 'copy'
|
|
171
|
+
? `Copy recognized ${type} content.`
|
|
172
|
+
: `Translate ${type} metadata into the ${harness} layout.`,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function capabilityTypeForPath(release: PluginRelease, path: string): string {
|
|
177
|
+
const match = release.capabilities.find((capability) => capability.paths.includes(path))
|
|
178
|
+
return match?.type ?? 'unknown'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function targetForType(type: string, path: string, directory: string, harness: Harness): string {
|
|
182
|
+
if (type === 'skill')
|
|
183
|
+
return `${targetLayout(harness).root}/skills/${directory || basenameWithoutExtension(path)}/${path.endsWith('SKILL.md') ? 'SKILL.md' : basenameWithoutExtension(path)}`
|
|
184
|
+
if (type === 'rule')
|
|
185
|
+
return `${targetLayout(harness).root}/rules/${basenameWithoutExtension(path)}.md`
|
|
186
|
+
if (type === 'command')
|
|
187
|
+
return `${targetLayout(harness).root}/commands/${basenameWithoutExtension(path)}.md`
|
|
188
|
+
if (type === 'agent')
|
|
189
|
+
return `${targetLayout(harness).root}/agents/${basenameWithoutExtension(path)}.md`
|
|
190
|
+
if (type === 'mcp-server')
|
|
191
|
+
return `${targetLayout(harness).root}/mcp/${basenameWithoutExtension(path)}.json`
|
|
192
|
+
return `${targetLayout(harness).root}/${path}`
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function basenameWithoutExtension(path: string): string {
|
|
196
|
+
const basename = path.split('/').at(-1) ?? path
|
|
197
|
+
return basename.replace(/\.[^.]+$/, '')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function supportedHarnesses(): readonly Harness[] {
|
|
201
|
+
return HarnessSchema.options
|
|
202
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import type { Catalog } from './schema.js'
|
|
4
|
+
import { byteDigest, type PublishedAsset } from './index.js'
|
|
5
|
+
import { iconAssetName, inspectIconBytes, type IconContentType } from './icons.js'
|
|
6
|
+
import type { ConsumerIndex } from './consumer-index.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where an icon's bytes come from, so a mirror needs no tree walk and no
|
|
10
|
+
* rediscovery: `content` refetches a file at the pinned commit, `favicon`
|
|
11
|
+
* refetches the site icon the catalog recorded.
|
|
12
|
+
*/
|
|
13
|
+
export type IconSource =
|
|
14
|
+
| {
|
|
15
|
+
readonly kind: 'content'
|
|
16
|
+
readonly asset: string
|
|
17
|
+
readonly digest: string
|
|
18
|
+
readonly bytes: number
|
|
19
|
+
readonly contentType: IconContentType
|
|
20
|
+
readonly repositoryUrl: string
|
|
21
|
+
readonly commitSha: string
|
|
22
|
+
readonly pluginSubdirectory: string
|
|
23
|
+
/** Path relative to `pluginSubdirectory`, as recorded in the catalog. */
|
|
24
|
+
readonly path: string
|
|
25
|
+
/** Path including the plugin subdirectory, for a raw content fetch. */
|
|
26
|
+
readonly sourcePath: string
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
readonly kind: 'favicon'
|
|
30
|
+
readonly asset: string
|
|
31
|
+
readonly digest: string
|
|
32
|
+
readonly bytes: number
|
|
33
|
+
readonly contentType: IconContentType
|
|
34
|
+
readonly url: string
|
|
35
|
+
readonly homepage: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface IconMirrorResult {
|
|
39
|
+
/** Icons whose verified bytes are present in the assets directory. */
|
|
40
|
+
readonly staged: readonly PublishedAsset[]
|
|
41
|
+
/** Icons that could not be staged, with the reason, never silently dropped. */
|
|
42
|
+
readonly skipped: readonly { readonly asset: string; readonly reason: string }[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Maps every mirrored icon back to the exact commit it was resolved from, by
|
|
47
|
+
* matching the digest the product advertises. Canonical variants are inspected
|
|
48
|
+
* first, mirroring the order product records were built in.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveIconSources(
|
|
51
|
+
catalog: Pick<Catalog, 'plugins'>,
|
|
52
|
+
index: ConsumerIndex
|
|
53
|
+
): readonly IconSource[] {
|
|
54
|
+
const byAsset = new Map<string, IconSource>()
|
|
55
|
+
for (const plugin of catalog.plugins) {
|
|
56
|
+
for (const release of plugin.availableReleases) {
|
|
57
|
+
const icon = release.icon
|
|
58
|
+
if (!icon) continue
|
|
59
|
+
const asset = iconAssetName(icon.digest, icon.contentType)
|
|
60
|
+
if (byAsset.has(asset)) continue
|
|
61
|
+
if (icon.kind === 'favicon') {
|
|
62
|
+
byAsset.set(asset, {
|
|
63
|
+
kind: 'favicon',
|
|
64
|
+
asset,
|
|
65
|
+
digest: icon.digest,
|
|
66
|
+
bytes: icon.bytes,
|
|
67
|
+
contentType: icon.contentType,
|
|
68
|
+
url: icon.url,
|
|
69
|
+
homepage: icon.homepage,
|
|
70
|
+
})
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
const subdirectory =
|
|
74
|
+
release.pluginSubdirectory && release.pluginSubdirectory !== '.'
|
|
75
|
+
? release.pluginSubdirectory
|
|
76
|
+
: ''
|
|
77
|
+
byAsset.set(asset, {
|
|
78
|
+
kind: 'content',
|
|
79
|
+
asset,
|
|
80
|
+
digest: icon.digest,
|
|
81
|
+
bytes: icon.bytes,
|
|
82
|
+
contentType: icon.contentType,
|
|
83
|
+
repositoryUrl: release.resolvedRepositoryUrl,
|
|
84
|
+
commitSha: release.resolvedCommitSha,
|
|
85
|
+
pluginSubdirectory: subdirectory,
|
|
86
|
+
path: icon.path,
|
|
87
|
+
sourcePath: subdirectory ? `${subdirectory}/${icon.path}` : icon.path,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const sources: IconSource[] = []
|
|
92
|
+
for (const product of Object.values(index.products)) {
|
|
93
|
+
const icon = product.icon
|
|
94
|
+
if (!icon) continue
|
|
95
|
+
const source = byAsset.get(icon.asset)
|
|
96
|
+
if (source) sources.push(source)
|
|
97
|
+
}
|
|
98
|
+
return sources.toSorted((left, right) => left.asset.localeCompare(right.asset))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Writes mirrored icon bytes beside the published artifacts.
|
|
103
|
+
*
|
|
104
|
+
* Bytes are verified against the digest the catalog advertises before they are
|
|
105
|
+
* staged, so a mirror can never publish content that differs from what the
|
|
106
|
+
* index promised. Staging never overwrites a differing file: content-addressed
|
|
107
|
+
* names make an existing file either identical or a build error.
|
|
108
|
+
*/
|
|
109
|
+
export async function stageIconAssets(input: {
|
|
110
|
+
readonly sources: readonly IconSource[]
|
|
111
|
+
readonly assetsDirectory: string
|
|
112
|
+
/** Reads the icon bytes for a source; live fetches raw content, offline reads fixtures. */
|
|
113
|
+
readonly readBytes: (source: IconSource) => Promise<Uint8Array>
|
|
114
|
+
}): Promise<IconMirrorResult> {
|
|
115
|
+
const staged: PublishedAsset[] = []
|
|
116
|
+
const skipped: { asset: string; reason: string }[] = []
|
|
117
|
+
await fs.mkdir(input.assetsDirectory, { recursive: true })
|
|
118
|
+
for (const source of input.sources) {
|
|
119
|
+
const destination = join(input.assetsDirectory, source.asset)
|
|
120
|
+
const existing = await fs.readFile(destination).catch(() => undefined)
|
|
121
|
+
if (existing && byteDigest(existing) === source.digest) {
|
|
122
|
+
staged.push(publishedAssetOf(source))
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (existing) {
|
|
126
|
+
skipped.push({ asset: source.asset, reason: 'ASSET_CONFLICT' })
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
let bytes: Uint8Array
|
|
130
|
+
try {
|
|
131
|
+
bytes = await input.readBytes(source)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
skipped.push({
|
|
134
|
+
asset: source.asset,
|
|
135
|
+
reason: `FETCH_FAILED: ${error instanceof Error ? error.message : String(error)}`,
|
|
136
|
+
})
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
const inspected = inspectIconBytes(bytes)
|
|
140
|
+
if (!('contentType' in inspected)) {
|
|
141
|
+
skipped.push({ asset: source.asset, reason: inspected.rejected })
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
if (inspected.contentType !== source.contentType) {
|
|
145
|
+
skipped.push({ asset: source.asset, reason: 'CONTENT_TYPE_CHANGED' })
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (byteDigest(bytes) !== source.digest || bytes.byteLength !== source.bytes) {
|
|
149
|
+
skipped.push({ asset: source.asset, reason: 'DIGEST_MISMATCH' })
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
await fs.mkdir(dirname(destination), { recursive: true })
|
|
153
|
+
await fs.writeFile(destination, bytes)
|
|
154
|
+
staged.push(publishedAssetOf(source))
|
|
155
|
+
}
|
|
156
|
+
// Distinct assets: two products can share one mark, and the catalog declares
|
|
157
|
+
// each asset once.
|
|
158
|
+
return { staged: dedupeAssets(staged), skipped }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function dedupeAssets(assets: readonly PublishedAsset[]): readonly PublishedAsset[] {
|
|
162
|
+
const unique = new Map<string, PublishedAsset>()
|
|
163
|
+
for (const asset of assets) unique.set(asset.name, asset)
|
|
164
|
+
return [...unique.values()]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function publishedAssetOf(source: IconSource): PublishedAsset {
|
|
168
|
+
return {
|
|
169
|
+
name: source.asset,
|
|
170
|
+
digest: source.digest,
|
|
171
|
+
bytes: source.bytes,
|
|
172
|
+
contentType: source.contentType,
|
|
173
|
+
kind: source.kind,
|
|
174
|
+
origin: source.kind === 'content' ? source.sourcePath : source.url,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Writes one verified icon into the assets directory.
|
|
180
|
+
*
|
|
181
|
+
* The build already holds the exact bytes it recorded a digest for, so staging
|
|
182
|
+
* them here removes the re-fetch a later mirror would need. That matters for
|
|
183
|
+
* site icons: their URL is not content-addressed, so a vendor can change the
|
|
184
|
+
* file between resolution and publication.
|
|
185
|
+
*/
|
|
186
|
+
export async function stageIconBytes(
|
|
187
|
+
assetsDirectory: string,
|
|
188
|
+
asset: string,
|
|
189
|
+
bytes: Uint8Array
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
const destination = join(assetsDirectory, asset)
|
|
192
|
+
const existing = await fs.readFile(destination).catch(() => undefined)
|
|
193
|
+
if (existing && byteDigest(existing) === byteDigest(bytes)) return
|
|
194
|
+
await fs.mkdir(dirname(destination), { recursive: true })
|
|
195
|
+
await fs.writeFile(destination, bytes)
|
|
196
|
+
}
|