-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmanifest.js
More file actions
417 lines (396 loc) · 17.3 KB
/
Copy pathmanifest.js
File metadata and controls
417 lines (396 loc) · 17.3 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
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
// @ts-check
import fs from 'node:fs/promises'
import path from 'node:path'
import { Attr, getLogger, withSpan } from './observability/index.js'
import { isPlainObject } from './util/json_util.js'
/**
* @import { PluginManifest, PluginName, PluginRequirements, PluginProvides, PluginPermission, PluginContributionManifest } from '../../hypaware-plugin-kernel-types.js'
* @import { FailedManifest, LoadedManifest, ManifestErrorKind } from '../../src/core/types.js'
*/
const MANIFEST_BASENAME = 'hypaware.plugin.json'
/**
* Read and validate the `hypaware.plugin.json` from a plugin directory.
* Emits a `manifest.load` span carrying `hyp_plugin`, `hyp_manifest_path`,
* `status`, and (on failure) `error_kind`. On failure also emits a
* `manifest.reject` log so query callers can find rejections without
* walking the trace tree.
*
* @param {string} rootDir
* @returns {Promise<LoadedManifest|FailedManifest>}
*/
export async function loadManifest(rootDir) {
const manifestPath = path.join(rootDir, MANIFEST_BASENAME)
/** @type {PluginManifest} */
let manifest
try {
manifest = await withSpan(
'manifest.load',
{
[Attr.OPERATION]: 'manifest.load',
hyp_manifest_path: manifestPath,
},
async (span) => {
let raw
try {
raw = await fs.readFile(manifestPath, 'utf8')
} catch (err) {
const code = err && /** @type {NodeJS.ErrnoException} */ (err).code
const detail = code === 'ENOENT'
? `manifest not found at ${manifestPath}`
: `failed to read manifest ${manifestPath}: ${describeError(err)}`
throw newManifestError('manifest_invalid', detail)
}
let parsed
try {
parsed = JSON.parse(raw)
} catch (err) {
throw newManifestError('manifest_invalid', `manifest is not valid JSON: ${describeError(err)}`)
}
const validation = validateManifest(parsed)
if (!validation.ok) {
throw newManifestError(validation.errorKind, validation.message)
}
span.setAttribute(Attr.PLUGIN, validation.manifest.name)
span.setAttribute('status', 'ok')
return validation.manifest
},
{ component: 'manifest' }
)
} catch (err) {
const errorKind = /** @type {ManifestErrorKind} */ (
(err && /** @type {{hypErrorKind?: string}} */ (err).hypErrorKind) || 'manifest_invalid'
)
const message = err instanceof Error ? err.message : String(err)
getLogger('manifest').error('manifest.reject', {
hyp_manifest_path: manifestPath,
[Attr.ERROR_KIND]: errorKind,
message,
})
return { ok: false, errorKind, message, manifestPath, rootDir }
}
// Outside the try above on purpose, and inside one of its own. Inside that
// try a throw from the diagnostic is caught as a manifest rejection and
// takes the whole plugin with it; outside it with no guard at all, the same
// throw rejects a promise this function has never rejected, and
// `loadManifests` fans out over `Promise.all`, so it would fail every
// plugin rather than one. A diagnostic may cost neither.
try {
warnUnrecognizedPickerPlatforms(manifest, manifestPath)
} catch {}
return { ok: true, manifest, manifestPath, rootDir }
}
/**
* The values `process.platform` can report (the `NodeJS.Platform` union, which
* is wider than the list the prose docs give). A picker row's `platforms` gate
* is matched against `process.platform` by string equality, so a value outside
* this set names no running platform.
*
* @ref LLP 0369#known-set: this list is the diagnostic's vocabulary, not the
* gate's, so a value Node adds later costs a spurious warning and nothing more.
*/
const KNOWN_PLATFORMS = new Set([
'aix', 'android', 'cygwin', 'darwin', 'freebsd', 'haiku',
'linux', 'netbsd', 'openbsd', 'sunos', 'win32',
])
/**
* Report picker `platforms` entries that name no known platform.
*
* Validation accepts them, because a rejection is fatal to the whole plugin
* and one mistyped display gate is not worth that. Unreported, the typo is
* invisible: the row is offered on no platform, and an absent row is what a
* row the author never wrote looks like too. The warning mirrors to stderr
* because the reader is a plugin author with no telemetry configured.
*
* @ref LLP 0369#warn-not-reject [implements]: an unrecognized platform is a load-time warning, never a refusal.
* @param {PluginManifest} manifest
* @param {string} manifestPath
*/
function warnUnrecognizedPickerPlatforms(manifest, manifestPath) {
for (const row of manifest.contributes?.picker ?? []) {
const platforms = row.platforms ?? []
const unrecognized = platforms.filter((p) => !KNOWN_PLATFORMS.has(p))
if (unrecognized.length === 0) continue
// Only a gate that is unrecognized end to end withholds the row
// everywhere. `["darwin", "win"]` carries the same typo but still renders
// on macOS, and telling that author the row is offered nowhere sends them
// hunting for a row they can see, which is the confusion this removes.
const detail = unrecognized.length === platforms.length
? `picker row is gated to ${unrecognized.join(', ')}, which no platform reports, so the row is offered nowhere`
: `picker row names ${unrecognized.join(', ')} in its gate, which no platform reports, so the row is offered only where the rest of the gate matches`
getLogger('manifest', { mirrorStderr: true }).warn('manifest.picker_platform_unrecognized', {
[Attr.PLUGIN]: manifest.name,
hyp_manifest_path: manifestPath,
hyp_picker_row: row.name,
hyp_unrecognized_platforms: unrecognized.join(','),
detail,
})
}
}
/**
* Load several manifests in parallel. The result splits into the loaded
* and failed bins so callers can short-circuit dep resolution when any
* manifest is invalid.
*
* @param {string[]} rootDirs
* @returns {Promise<{ loaded: LoadedManifest[], failed: FailedManifest[] }>}
*/
export async function loadManifests(rootDirs) {
const results = await Promise.all(rootDirs.map((d) => loadManifest(d)))
/** @type {LoadedManifest[]} */
const loaded = []
/** @type {FailedManifest[]} */
const failed = []
for (const r of results) {
if (r.ok) loaded.push(r)
else failed.push(r)
}
return { loaded, failed }
}
/**
* Pure validator over a parsed JSON value. Used directly by tests and
* by `loadManifest`. Checks the V1 fields the kernel relies on; the
* extended `contributes` block is accepted opaquely and validated by
* the registries that consume it.
*
* @param {unknown} value
* @returns {{ ok: true, manifest: PluginManifest } | { ok: false, errorKind: ManifestErrorKind, message: string }}
* @ref LLP 0005#declarative [implements]: one manifest shape declares requires/provides/contributes; category is emergent, not a variant
*/
export function validateManifest(value) {
if (!isPlainObject(value)) {
return invalid('manifest must be a JSON object')
}
const m = /** @type {Record<string, unknown>} */ (value)
if (m.schema_version !== 1) return invalid('schema_version must be 1')
if (!isNonEmptyString(m.name)) return invalid('name (string) is required')
if (!isNonEmptyString(m.version)) return invalid('version (string) is required')
if (!isNonEmptyString(m.hypaware_api)) return invalid('hypaware_api (string semver range) is required')
if (m.runtime !== 'node') return invalid("runtime must be 'node'")
if (!isNonEmptyString(m.entrypoint)) return invalid('entrypoint (string) is required')
if (m.description !== undefined && typeof m.description !== 'string') {
return invalid('description must be a string when present')
}
if (m.node_engine !== undefined && typeof m.node_engine !== 'string') {
return invalid('node_engine must be a string when present')
}
if (m.requires !== undefined) {
if (!isPlainObject(m.requires)) return invalid('requires must be an object')
const r = /** @type {Record<string, unknown>} */ (m.requires)
if (r.plugins !== undefined && !isStringMap(r.plugins)) {
return invalid('requires.plugins must be a map of plugin name -> semver range')
}
if (r.capabilities !== undefined && !isStringMap(r.capabilities)) {
return invalid('requires.capabilities must be a map of capability name -> semver range')
}
}
if (m.provides !== undefined) {
if (!isPlainObject(m.provides)) return invalid('provides must be an object')
const p = /** @type {Record<string, unknown>} */ (m.provides)
if (p.capabilities !== undefined && !isStringMap(p.capabilities)) {
return invalid('provides.capabilities must be a map of capability name -> version')
}
}
if (m.permissions !== undefined && !isStringArray(m.permissions)) {
return invalid('permissions must be a string array')
}
// @ref LLP 0213#d1 [implements]: a derived-data plugin rides a pick it does not contribute
if (m.compose_with !== undefined) {
if (!isStringArray(m.compose_with) || m.compose_with.length === 0) {
return invalid('compose_with must be a non-empty array of plugin names when present')
}
// A plugin that waits for itself can never be composed: the fixpoint
// only adds a rider once every name it waits for is already present,
// and this one never will be. That terminates safely, which is exactly
// the problem - it composes nothing and reports nothing, so the plugin
// is simply missing from every config with no error to read. Rejecting
// it here is the only layer that can tell the author.
if (m.compose_with.includes(m.name)) {
return invalid('compose_with must not name its own plugin: a plugin cannot ride itself')
}
}
if (m.contributes !== undefined && !isPlainObject(m.contributes)) {
return invalid('contributes must be an object when present')
}
if (isPlainObject(m.contributes)) {
const pickerCheck = validatePickerContributions(
/** @type {Record<string, unknown>} */ (m.contributes).picker
)
if (!pickerCheck.ok) return pickerCheck
const commandCheck = validateCommandContributions(
/** @type {Record<string, unknown>} */ (m.contributes).commands
)
if (!commandCheck.ok) return commandCheck
}
/** @type {PluginManifest} */
const manifest = {
schema_version: 1,
name: m.name,
version: m.version,
hypaware_api: m.hypaware_api,
runtime: 'node',
entrypoint: m.entrypoint,
}
if (typeof m.description === 'string') manifest.description = m.description
if (typeof m.node_engine === 'string') manifest.node_engine = m.node_engine
if (isPlainObject(m.requires)) manifest.requires = /** @type {PluginRequirements} */ (m.requires)
if (isPlainObject(m.provides)) manifest.provides = /** @type {PluginProvides} */ (m.provides)
if (isStringArray(m.permissions)) manifest.permissions = /** @type {PluginPermission[]} */ (m.permissions)
if (isStringArray(m.compose_with)) manifest.compose_with = /** @type {PluginName[]} */ (m.compose_with)
if (isPlainObject(m.contributes)) manifest.contributes = /** @type {PluginContributionManifest} */ (m.contributes)
return { ok: true, manifest }
}
/**
* Validate the `hidden` flag on `contributes.commands` rows, and only
* that flag. Everything else about a command entry (`name`, `summary`,
* `usage`, unknown fields) stays opaque here, like every sibling
* contribution category, and keeps its existing home: each read site
* already coerces defensively, and `hyp plugin doctor` reports a
* malformed entry with its field path and a repair line. A fatal
* manifest rejection cannot do that - it aborts `loadManifest`, so the
* doctor never reaches its shape checks, and a mistyped help summary
* takes the plugin's sources, sinks and datasets down with it.
*
* `hidden` is the exception because it is not help metadata: it decides
* whether a command is CLI surface at all, so a manifest spelling it
* `"true"` would advertise an internal mechanism with nothing to say
* so. That is worth refusing the manifest over; a mistyped summary is
* not.
*
* @param {unknown} commands
* @returns {{ ok: true } | { ok: false, errorKind: ManifestErrorKind, message: string }}
* @ref LLP 0268#field [implements]: internal commands stay declared and are marked, not deleted
*/
function validateCommandContributions(commands) {
if (!Array.isArray(commands)) return { ok: true }
for (const row of commands) {
if (!isPlainObject(row)) continue
const hidden = /** @type {Record<string, unknown>} */ (row).hidden
if (hidden !== undefined && typeof hidden !== 'boolean') {
return invalid('contributes.commands hidden must be a boolean when present')
}
}
return { ok: true }
}
/**
* Recognized `PickerDetectProbe` variant keys. Exactly one must be
* present, carrying a non-empty string path.
* @ref LLP 0130#picker-block: the declarative picker probe variants.
*/
const PICKER_PROBE_KEYS = ['settings_file', 'app_bundle', 'path']
/**
* Validate `contributes.picker`. It is optional; when present it must be
* an array of picker rows, each with a `name` (the picker source id
* that keys the row) and `label` string, and, optionally, a `summary`
* string, a single-variant `detect` probe, a `hidden` boolean, a
* `platforms` gate, a `needs_setup` boolean, and
* a `configure_command` string. Unknown fields are accepted (kept
* opaque like the rest of the `contributes` block) so later additions
* such as `compose` pass through untouched.
*
* @param {unknown} picker
* @returns {{ ok: true } | { ok: false, errorKind: ManifestErrorKind, message: string }}
*/
function validatePickerContributions(picker) {
if (picker === undefined) return { ok: true }
if (!Array.isArray(picker)) {
return invalid('contributes.picker must be an array when present')
}
for (const row of picker) {
if (!isPlainObject(row)) {
return invalid('contributes.picker entries must be objects')
}
const r = /** @type {Record<string, unknown>} */ (row)
if (!isNonEmptyString(r.name)) {
return invalid('contributes.picker entries require a name (string)')
}
if (!isNonEmptyString(r.label)) {
return invalid('contributes.picker entries require a label (string)')
}
if (r.summary !== undefined && typeof r.summary !== 'string') {
return invalid('contributes.picker summary must be a string when present')
}
if (r.hidden !== undefined && typeof r.hidden !== 'boolean') {
return invalid('contributes.picker hidden must be a boolean when present')
}
// @ref LLP 0368#platform-gate [implements]: a row may name the platforms it is offered on, which `detect` cannot say because a probe may only pre-check
// @ref LLP 0369#warn-not-reject [constrained-by]: the string values stay unchecked here; an unrecognized one is reported by `warnUnrecognizedPickerPlatforms`, not refused
if (r.platforms !== undefined && !(Array.isArray(r.platforms) && r.platforms.length > 0 && r.platforms.every(isNonEmptyString))) {
return invalid('contributes.picker platforms must be a non-empty array of process.platform values when present')
}
if (r.needs_setup !== undefined && typeof r.needs_setup !== 'boolean') {
return invalid('contributes.picker needs_setup must be a boolean when present')
}
if (r.configure_command !== undefined && typeof r.configure_command !== 'string') {
return invalid('contributes.picker configure_command must be a string when present')
}
if (r.detect !== undefined) {
const detectCheck = validatePickerProbe(r.detect)
if (!detectCheck.ok) return detectCheck
}
}
return { ok: true }
}
/**
* Validate a single `PickerDetectProbe`: a plain object carrying exactly
* one recognized variant key (`settings_file` / `app_bundle` / `path`)
* whose value is a non-empty string.
*
* @param {unknown} detect
* @returns {{ ok: true } | { ok: false, errorKind: ManifestErrorKind, message: string }}
*/
function validatePickerProbe(detect) {
if (!isPlainObject(detect)) {
return invalid('contributes.picker detect must be an object when present')
}
const d = /** @type {Record<string, unknown>} */ (detect)
const present = PICKER_PROBE_KEYS.filter((k) => d[k] !== undefined)
if (present.length !== 1) {
return invalid(
`contributes.picker detect must set exactly one of ${PICKER_PROBE_KEYS.join(', ')}`
)
}
if (!isNonEmptyString(d[present[0]])) {
return invalid(`contributes.picker detect.${present[0]} must be a non-empty string`)
}
return { ok: true }
}
/**
* @param {ManifestErrorKind} errorKind
* @param {string} message
*/
function newManifestError(errorKind, message) {
const err = /** @type {Error & { hypErrorKind?: string }} */ (new Error(message))
err.hypErrorKind = errorKind
return err
}
/** @param {string} message */
function invalid(message) {
return /** @type {const} */ ({ ok: false, errorKind: 'manifest_invalid', message })
}
/**
* @param {unknown} v
* @returns {v is string}
*/
function isNonEmptyString(v) {
return typeof v === 'string' && v.length > 0
}
/** @param {unknown} v */
function isStringMap(v) {
if (!isPlainObject(v)) return false
for (const value of Object.values(/** @type {Record<string, unknown>} */ (v))) {
if (typeof value !== 'string') return false
}
return true
}
/**
* @param {unknown} v
* @returns {v is string[]}
*/
function isStringArray(v) {
return Array.isArray(v) && v.every((x) => typeof x === 'string')
}
/** @param {unknown} err */
function describeError(err) {
if (err instanceof Error) return err.message
return String(err)
}