-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplugin-declaration.ts
More file actions
365 lines (329 loc) · 14.8 KB
/
Copy pathplugin-declaration.ts
File metadata and controls
365 lines (329 loc) · 14.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
import * as nodeFs from "fs/promises"
import * as path from "path"
import { pluginManifestSchema } from "@shofer/types"
import { z } from "zod"
import { EMPTY_LOCKED_MANIFEST, isPathLocked, type LockedManifest } from "../config/layered-config.js"
import { fetchPluginArchive, isPluginUrl, unpackPlugin } from "./plugin-pack.js"
/**
* plugin-declaration — the `.shofer/plugins.json` *declaration* of which plugins a
* scope wants, from where, and at which version — plus the resolver that
* materializes each declared `source@version` into a content-addressed cache dir
* (todos/config-cleanup.md Part F: "plugins as `.shofer/` declarations — declare,
* don't vendor").
*
* The gap Part F fills: plugin *code* is already `.shofer/`-hosted and plugin
* *config* already flows through `.shofer/settings.json`, but there was no
* **declaration** of the plugin set/source/version — a plugin was just an
* installed directory with its own `plugin.json`. This module is that declaration
* layer:
*
* 1. {@link pluginDeclarationSchema} — the on-disk `.shofer/plugins.json` shape
* (Zod, versioned, fail-closed), parsed via {@link parsePluginDeclaration}.
* 2. {@link mergePluginDeclarations} — the pure three-scope cross-merge, reusing
* the same locked-vs-default engine as `layered-config` (a plugin whose
* `plugins/<name>` path is org-locked has the global scope's entry win and be
* final; unlocked entries follow more-specific-wins, `project > user > global`).
* 3. {@link resolvePluginDeclaration} — the resolver/installer: it materializes
* each declared `source@version` into `<cacheBaseDir>/<name>@<version>/` (the
* **bytes are never committed to `.shofer/`** — only the declaration is), so
* `.shofer/` stays text-only, reproducible, and zip/overlay-able.
*
* This module is the pure half: the host wiring lives in the host's
* `src/core/config/pluginDeclarationLoader.ts`, which `ShoferProvider` drives
* (`loadPluginDeclarations` + `computePluginDeclarationWiring`) to seed
* `pluginDirs` / `pluginConfigs` / enablement from
* {@link resolvePluginDeclaration}'s output. Node-only (uses `node:fs`/`node:path` and the
* `unpackPlugin` archive path, like `plugin-pack.ts`) — it imports no `vscode`.
*/
/**
* Current on-disk version of `.shofer/plugins.json`. Bump when the declaration
* shape changes; a mismatched version is discarded (Versioned Snapshot Rule).
*/
export const PLUGIN_DECLARATION_VERSION = 1
/**
* One plugin's declaration entry (Schema-First Persistence Rule). `source` is a
* local directory path, a local `.shofer-plugin` archive path, or an **http(s)
* URL** to such an archive. A content-addressed URL
* (`.../sha256-<hex>.shofer-plugin`) additionally pins the bytes: the resolver
* verifies the digest and refuses a mismatch. `version` is the author-declared version the resolver
* materializes under. `config` is the user's config overrides for the plugin
* (merged with manifest defaults downstream); `enabled` defaults to `true`.
*/
export const pluginDeclarationEntrySchema = z
.object({
source: z.string().min(1),
version: z.string().min(1),
config: z.record(z.string(), z.unknown()).optional(),
enabled: z.boolean().optional(),
})
.strict()
export type PluginDeclarationEntry = z.infer<typeof pluginDeclarationEntrySchema>
/**
* Schema for `.shofer/plugins.json` (design Part F). Validated fail-closed: unknown
* keys are rejected and a mismatched `version` is discarded on load. `plugins` maps
* a plugin **name** to its {@link PluginDeclarationEntry}.
*/
export const pluginDeclarationSchema = z
.object({
version: z.literal(PLUGIN_DECLARATION_VERSION),
plugins: z.record(z.string(), pluginDeclarationEntrySchema),
})
.strict()
export type PluginDeclaration = z.infer<typeof pluginDeclarationSchema>
/** An empty declaration — nothing declared. Returned when parsing fails closed. */
export const EMPTY_PLUGIN_DECLARATION: PluginDeclaration = { version: PLUGIN_DECLARATION_VERSION, plugins: {} }
/**
* Parse raw `.shofer/plugins.json` content into a {@link PluginDeclaration}, failing
* closed (Schema-First Persistence Rule + Versioned Snapshot Rule): corrupt JSON, a
* shape mismatch, or a version mismatch all yield {@link EMPTY_PLUGIN_DECLARATION}
* rather than throwing. Accepts either the raw file **string** (JSON-parsed here) or
* an already-parsed object.
*/
export function parsePluginDeclaration(raw: unknown): PluginDeclaration {
let json: unknown = raw
if (typeof raw === "string") {
try {
json = JSON.parse(raw)
} catch {
return EMPTY_PLUGIN_DECLARATION
}
}
const result = pluginDeclarationSchema.safeParse(json)
return result.success ? result.data : EMPTY_PLUGIN_DECLARATION
}
/** The three scope layers of `.shofer/plugins.json`, least- to most-specific. */
export interface PluginDeclarationLayers {
/** Org-global scope (read-only; the sole lock authority via `locked.json`). */
global?: PluginDeclaration
/** Per-user scope (`~/.shofer/`), overrides global when unlocked. */
user?: PluginDeclaration
/** Project scope (`<workspace>/.shofer/`), most specific, wins when unlocked. */
project?: PluginDeclaration
}
/**
* Cross-merge the three scopes' plugin declarations, per plugin name, under the same
* locked-vs-default rule as `layered-config` (todos/config-cleanup.md Part E/F). For
* each declared name:
*
* - **Locked** (`plugins/<name>` in the global scope's `locked.json`) **and** the
* global scope declares it → the **global** entry wins and is final; user/project
* entries for that name are dropped. "These plugins, these versions — non-negotiable."
* - **Unlocked** (or global does not declare it) → more-specific wins:
* `project ?? user ?? global` (whole-entry replacement).
* - A user/project may always **add** plugins the global scope did not declare —
* locking a name global never set is meaningless and falls back to the unlocked merge.
*
* Pure and non-mutating: inputs are untouched, the result is a fresh
* {@link PluginDeclaration}. Reuses {@link isPathLocked} from `layered-config` — the
* lock predicate is not re-implemented here.
*/
export function mergePluginDeclarations(
layers: PluginDeclarationLayers,
manifest: LockedManifest = EMPTY_LOCKED_MANIFEST,
): PluginDeclaration {
const globalPlugins = layers.global?.plugins ?? {}
const userPlugins = layers.user?.plugins ?? {}
const projectPlugins = layers.project?.plugins ?? {}
const names = new Set<string>([
...Object.keys(globalPlugins),
...Object.keys(userPlugins),
...Object.keys(projectPlugins),
])
const merged: Record<string, PluginDeclarationEntry> = {}
for (const name of names) {
const globalEntry = globalPlugins[name]
if (globalEntry !== undefined && isPathLocked(`plugins/${name}`, manifest)) {
// Org-locked and defined by global → global wins, final.
merged[name] = globalEntry
continue
}
// Unlocked (or not declared by global) → more-specific wins.
const winner = projectPlugins[name] ?? userPlugins[name] ?? globalEntry
if (winner !== undefined) merged[name] = winner
}
return { version: PLUGIN_DECLARATION_VERSION, plugins: merged }
}
/**
* A declared plugin materialized into the cache and validated — the shape the host
* adds to `pluginDirs` and uses to seed `pluginConfigs` / enablement (the wiring is
* a later pass).
*/
export interface ResolvedPlugin {
/** The plugin name (declaration key; also the validated manifest `name`). */
name: string
/** Absolute cache directory the plugin was materialized into (`<cacheBaseDir>/<name>@<version>`). */
dir: string
/** The declared version the plugin was materialized under. */
version: string
/** The user's config overrides for this plugin, if any (from the declaration entry). */
config?: Record<string, unknown>
/** Whether the plugin is enabled (declaration `enabled`, defaulting to `true`). */
enabled: boolean
}
/** A per-plugin resolution failure that did not abort the batch (name/manifest mismatch). */
export interface PluginResolveWarning {
/** The declaration name that failed to resolve. */
name: string
/** Why it was skipped (missing/invalid manifest, or a name mismatch). */
message: string
}
/** The result of {@link resolvePluginDeclaration}: the resolved plugins plus per-plugin skips. */
export interface ResolvePluginsResult {
/** Plugins that materialized and validated — safe for the host to consume. */
resolved: ResolvedPlugin[]
/** Plugins skipped with a non-fatal error (the batch still returns the rest). */
errors: PluginResolveWarning[]
}
/**
* Thrown when a declared source cannot be materialized at all — a missing local
* path, an unreachable URL, or an archive whose bytes do not match the digest a
* content-addressed URL pins. A distinct class so a caller can present it as a
* user error rather than a crash. This is a **hard** failure (it rejects the
* whole batch), unlike a per-plugin manifest mismatch which is skipped softly:
* a source that will not materialize is a broken declaration, and a digest
* mismatch specifically may be a swapped artifact.
*/
export class PluginResolveError extends Error {
constructor(message: string) {
super(message)
this.name = "PluginResolveError"
}
}
const MANIFEST_FILENAME = "plugin.json"
/** Whether a path exists on disk. */
async function pathExists(target: string): Promise<boolean> {
try {
await nodeFs.access(target)
return true
} catch {
return false
}
}
/**
* Unpack archive bytes (or a local archive path) into the versioned `cacheDir`.
*
* {@link unpackPlugin} installs as `<dest>/<manifestName>`, so it runs against a
* scratch staging dir which is then renamed onto `cacheDir` — that is what makes
* the final directory `<cacheBaseDir>/<name>@<version>` regardless of what the
* manifest calls itself.
*/
async function unpackIntoCacheDir(archive: Buffer | string, cacheDir: string): Promise<void> {
const staging = `${cacheDir}.staging-${Date.now()}-${Math.random().toString(36).slice(2)}`
try {
const installed = await unpackPlugin(archive, staging)
await nodeFs.rename(installed.dir, cacheDir)
} finally {
await nodeFs.rm(staging, { recursive: true, force: true }).catch(() => {})
}
}
/**
* Materialize one declared `source` into `cacheDir` (idempotency is the caller's
* responsibility — it is only invoked when `cacheDir` has no manifest yet).
*
* Three source kinds: a **directory** is copied tree-wise; a local
* **`.shofer-plugin` archive** is unpacked; an **http(s) URL** is downloaded and
* then unpacked through the very same path, so the archive hardening (manifest
* validation, zip-slip, link entries) applies identically no matter where the
* bytes came from. A content-addressed URL additionally pins the bytes by digest
* — {@link fetchPluginArchive} refuses a mismatch, so a URL that starts serving
* different code fails the load rather than silently swapping it.
*/
async function materializeSource(source: string, cacheDir: string): Promise<void> {
if (isPluginUrl(source)) {
// Fetch BEFORE clearing the target: a failed download must not destroy an
// existing materialization.
const bytes = await fetchPluginArchive(source).catch((error) => {
throw new PluginResolveError(
`Cannot resolve plugin from "${source}": ${error instanceof Error ? error.message : String(error)}`,
)
})
await nodeFs.rm(cacheDir, { recursive: true, force: true })
await nodeFs.mkdir(path.dirname(cacheDir), { recursive: true })
await unpackIntoCacheDir(bytes, cacheDir)
return
}
const stat = await nodeFs.stat(source).catch(() => {
throw new PluginResolveError(`Plugin source not found: ${source}`)
})
// Start from a clean target so a partial prior materialization cannot leak in.
await nodeFs.rm(cacheDir, { recursive: true, force: true })
await nodeFs.mkdir(path.dirname(cacheDir), { recursive: true })
if (stat.isDirectory()) {
await nodeFs.cp(source, cacheDir, { recursive: true })
return
}
await unpackIntoCacheDir(source, cacheDir)
}
/**
* Resolve a merged {@link PluginDeclaration} into installed, validated plugins under
* `cacheBaseDir` (design Part F resolver/installer). For each declared plugin:
*
* - Materialize `source@version` into `<cacheBaseDir>/<name>@<version>/`,
* **idempotently**: if that dir already has a `plugin.json` it is reused as-is
* (no re-copy/re-unpack).
* - **Local directory** source → copied; **local `.shofer-plugin` archive** →
* unpacked; **http(s) URL** → downloaded (https-only unless loopback,
* size-capped) and unpacked through the same hardened path. A
* content-addressed URL (`.../sha256-<hex>.shofer-plugin`) is verified
* against that digest and a mismatch is refused.
* - A source that cannot be materialized at all — missing path, unreachable
* URL, digest mismatch — raises {@link PluginResolveError}, a hard failure
* that rejects the whole batch.
* - The materialized dir's `plugin.json` is validated against
* {@link pluginManifestSchema} and its `name` checked against the declaration key;
* a missing/invalid manifest or a name mismatch **skips that one plugin** with a
* {@link PluginResolveWarning} (the batch still returns the rest).
*
* Returns the resolved plugins for the host to consume — this pass does NOT itself wire
* them into `pluginDirs`/`pluginConfigs`/enablement.
*/
export async function resolvePluginDeclaration(
decl: PluginDeclaration,
cacheBaseDir: string,
): Promise<ResolvePluginsResult> {
const resolved: ResolvedPlugin[] = []
const errors: PluginResolveWarning[] = []
for (const [name, entry] of Object.entries(decl.plugins)) {
const cacheDir = path.join(cacheBaseDir, `${name}@${entry.version}`)
const manifestPath = path.join(cacheDir, MANIFEST_FILENAME)
// Idempotent: an already-materialized cache dir (manifest present) is reused.
if (!(await pathExists(manifestPath))) {
await materializeSource(entry.source, cacheDir)
}
let manifestRaw: string
try {
manifestRaw = await nodeFs.readFile(manifestPath, "utf-8")
} catch {
errors.push({ name, message: `materialized plugin has no ${MANIFEST_FILENAME} at ${cacheDir}` })
continue
}
let manifestJson: unknown
try {
manifestJson = JSON.parse(manifestRaw)
} catch (error) {
errors.push({ name, message: `invalid JSON in ${manifestPath}: ${String(error)}` })
continue
}
const parsed = pluginManifestSchema.safeParse(manifestJson)
if (!parsed.success) {
const issues = parsed.error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join("; ")
errors.push({ name, message: `invalid manifest in ${manifestPath}: ${issues}` })
continue
}
if (parsed.data.name !== name) {
errors.push({
name,
message: `manifest name "${parsed.data.name}" does not match declared name "${name}"`,
})
continue
}
resolved.push({
name,
dir: cacheDir,
version: entry.version,
config: entry.config,
enabled: entry.enabled ?? true,
})
}
return { resolved, errors }
}