@hoardodile/host 0.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 +18 -0
- package/README.md +73 -0
- package/dist/chunks/worker-entry.mjs +422 -0
- package/dist/container-fKxefO7p.d.ts +58 -0
- package/dist/contract/index.d.ts +49 -0
- package/dist/contract/index.js +187 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/hoard/index.d.ts +754 -0
- package/dist/hoard/index.js +1939 -0
- package/dist/hoard/index.js.map +1 -0
- package/dist/image-Dx2Be3Bj.d.ts +168 -0
- package/dist/index.d.ts +788 -0
- package/dist/index.js +3311 -0
- package/dist/index.js.map +1 -0
- package/dist/media/index.d.ts +96 -0
- package/dist/media/index.js +152 -0
- package/dist/media/index.js.map +1 -0
- package/dist/probe/index.d.ts +113 -0
- package/dist/probe/index.js +454 -0
- package/dist/probe/index.js.map +1 -0
- package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
- package/dist/render/index.d.ts +125 -0
- package/dist/render/index.js +430 -0
- package/dist/render/index.js.map +1 -0
- package/package.json +103 -0
- package/src/__testutils__/zip-fixtures.ts +139 -0
- package/src/activation.ts +116 -0
- package/src/api-types.ts +60 -0
- package/src/api.test.ts +564 -0
- package/src/api.ts +501 -0
- package/src/archive/7z.test.ts +222 -0
- package/src/archive/7z.ts +261 -0
- package/src/archive/archive-ops.test.ts +654 -0
- package/src/archive/extract-archive.test.ts +237 -0
- package/src/archive/extract-archive.ts +534 -0
- package/src/archive/extract.ts +693 -0
- package/src/archive/format.ts +49 -0
- package/src/archive/index.ts +116 -0
- package/src/archive/listing.ts +121 -0
- package/src/archive/materialize.ts +88 -0
- package/src/archive/name-decode.test.ts +34 -0
- package/src/archive/name-decode.ts +287 -0
- package/src/archive/nested-cd-cache.ts +55 -0
- package/src/archive/nested-entry.test.ts +194 -0
- package/src/archive/nested-entry.ts +254 -0
- package/src/archive/pack.ts +40 -0
- package/src/archive/zip-entries.test.ts +56 -0
- package/src/archive/zip-entries.ts +336 -0
- package/src/capability-guard.ts +73 -0
- package/src/container.ts +62 -0
- package/src/contract/container-fixture.ts +74 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/suite.test.ts +101 -0
- package/src/contract/suite.ts +205 -0
- package/src/define-plugin.test.ts +53 -0
- package/src/define-plugin.ts +12 -0
- package/src/dev-runner.test.ts +98 -0
- package/src/dev-runner.ts +73 -0
- package/src/directory-api.test.ts +229 -0
- package/src/directory-api.ts +42 -0
- package/src/directory-container.ts +141 -0
- package/src/discovery.ts +295 -0
- package/src/errors.ts +74 -0
- package/src/fixtures.ts +8 -0
- package/src/hash.test.ts +301 -0
- package/src/hash.ts +167 -0
- package/src/hoard/dir-size.ts +58 -0
- package/src/hoard/index.ts +84 -0
- package/src/hoard/migrate-script.test.ts +539 -0
- package/src/hoard/order-manifest.test.ts +115 -0
- package/src/hoard/order-manifest.ts +131 -0
- package/src/hoard/paths.test.ts +226 -0
- package/src/hoard/paths.ts +569 -0
- package/src/hoard/plugin-vault.test.ts +160 -0
- package/src/hoard/plugin-vault.ts +233 -0
- package/src/hoard/sanitize.test.ts +149 -0
- package/src/hoard/sanitize.ts +230 -0
- package/src/hoard/staging-dir.ts +133 -0
- package/src/hoard/version.test.ts +285 -0
- package/src/hoard/version.ts +216 -0
- package/src/hoard/versioned-folder-ops.ts +152 -0
- package/src/hoard/write-versioned.test.ts +67 -0
- package/src/hoard/write-versioned.ts +39 -0
- package/src/hooks.test.ts +528 -0
- package/src/hooks.ts +428 -0
- package/src/index.ts +135 -0
- package/src/loader.test.ts +442 -0
- package/src/loader.ts +213 -0
- package/src/media/index.ts +16 -0
- package/src/media/render-cache.ts +27 -0
- package/src/media/seekable.ts +35 -0
- package/src/media/thumb-input.test.ts +109 -0
- package/src/media/thumb-input.ts +194 -0
- package/src/nested-view.test.ts +223 -0
- package/src/nested-view.ts +239 -0
- package/src/probe/audio.test.ts +135 -0
- package/src/probe/audio.ts +140 -0
- package/src/probe/av.ts +84 -0
- package/src/probe/ffprobe.ts +197 -0
- package/src/probe/image.test.ts +251 -0
- package/src/probe/image.ts +336 -0
- package/src/probe/index.ts +54 -0
- package/src/probe/sniff.test.ts +72 -0
- package/src/probe/sniff.ts +67 -0
- package/src/probe/video.test.ts +41 -0
- package/src/probe/video.ts +116 -0
- package/src/probe-cache.test.ts +89 -0
- package/src/probe-cache.ts +65 -0
- package/src/render/ffmpeg.ts +78 -0
- package/src/render/index.ts +34 -0
- package/src/render/pipeline.test.ts +187 -0
- package/src/render/pipeline.ts +479 -0
- package/src/sandbox/call-timers.ts +68 -0
- package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
- package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
- package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
- package/src/sandbox/fixtures/container-plugin.mjs +8 -0
- package/src/sandbox/fixtures/context-plugin.mjs +19 -0
- package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
- package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
- package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
- package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
- package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
- package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
- package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
- package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
- package/src/sandbox/fixtures/range-plugin.mjs +9 -0
- package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
- package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
- package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
- package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
- package/src/sandbox/host.test.ts +619 -0
- package/src/sandbox/host.ts +933 -0
- package/src/sandbox/host.unit.test.ts +330 -0
- package/src/sandbox/protocol.test.ts +85 -0
- package/src/sandbox/protocol.ts +147 -0
- package/src/sandbox/sandboxed-plugin.ts +30 -0
- package/src/sandbox/worker-entry.mjs +422 -0
- package/src/seed.test.ts +102 -0
- package/src/seed.ts +160 -0
- package/src/settings-store.ts +26 -0
- package/src/types.ts +21 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PluginSchema } from "@hoardodile/sdk-types"
|
|
2
|
+
import { PLUGIN_READ_FILE_MAX_BYTES } from "@hoardodile/sdk-types/plugin"
|
|
3
|
+
import { createPluginResourceAPI } from "./api.ts"
|
|
4
|
+
import { createDirectoryContainer } from "./directory-container.ts"
|
|
5
|
+
import type { ResourceAPI } from "./types.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a minimal {@link ResourceAPI} backed by a raw filesystem
|
|
9
|
+
* directory. Used to run plugin hooks against a plain directory — during
|
|
10
|
+
* import, before resources exist, or from the CLI. Built on the shared
|
|
11
|
+
* {@link createPluginResourceAPI} so directory behavior matches the
|
|
12
|
+
* archive-backed and fixture backends; probes are unsupported and resolve
|
|
13
|
+
* to `undefined`.
|
|
14
|
+
*/
|
|
15
|
+
export function createDirectoryResourceAPI<
|
|
16
|
+
TSchema extends PluginSchema = PluginSchema,
|
|
17
|
+
>(
|
|
18
|
+
dir: string,
|
|
19
|
+
opts: {
|
|
20
|
+
readonly maxReadFileBytes?: number
|
|
21
|
+
/**
|
|
22
|
+
* Writable directory for `extractArchive` materialization (the
|
|
23
|
+
* CLI passes a temp dir). Virtual-path reads (`outer!inner`) work
|
|
24
|
+
* without it; only the materializing hook needs it.
|
|
25
|
+
*/
|
|
26
|
+
readonly extractCacheDir?: string
|
|
27
|
+
/**
|
|
28
|
+
* Session context handed to hooks as `api.context.detect` (see
|
|
29
|
+
* `CreatePluginResourceAPIDeps.detectContext`).
|
|
30
|
+
*/
|
|
31
|
+
readonly detectContext?: unknown
|
|
32
|
+
} = {},
|
|
33
|
+
): ResourceAPI<TSchema> {
|
|
34
|
+
return createPluginResourceAPI<TSchema>({
|
|
35
|
+
view: createDirectoryContainer(dir),
|
|
36
|
+
maxReadFileBytes: opts.maxReadFileBytes ?? PLUGIN_READ_FILE_MAX_BYTES,
|
|
37
|
+
extractCacheDir: opts.extractCacheDir,
|
|
38
|
+
detectContext: opts.detectContext,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { resolveSafeImportPath } from "./directory-container.ts"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs"
|
|
2
|
+
import { open, readdir, stat } from "node:fs/promises"
|
|
3
|
+
import { isAbsolute, join, normalize, resolve, sep } from "node:path"
|
|
4
|
+
import type { Readable } from "node:stream"
|
|
5
|
+
import type { ResourceContainer } from "./container.ts"
|
|
6
|
+
import {
|
|
7
|
+
naturalSort,
|
|
8
|
+
orderEntries,
|
|
9
|
+
readOrderManifest,
|
|
10
|
+
} from "./hoard/order-manifest.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A {@link ResourceContainer} over a raw filesystem directory. Used to
|
|
14
|
+
* run plugin hooks against a plain directory — during import, before
|
|
15
|
+
* resources exist, or from the CLI. Reads only; the directory is treated
|
|
16
|
+
* as an immutable snapshot for the container's lifetime.
|
|
17
|
+
*/
|
|
18
|
+
export function createDirectoryContainer(dir: string): ResourceContainer {
|
|
19
|
+
async function resolvePath(relPath: string): Promise<string> {
|
|
20
|
+
return resolveSafeImportPath(dir, relPath)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
async listEntries(): Promise<readonly string[]> {
|
|
25
|
+
const out: string[] = []
|
|
26
|
+
async function collect(current: string, prefix: string): Promise<void> {
|
|
27
|
+
const entries = await readdir(join(dir, current), {
|
|
28
|
+
withFileTypes: true,
|
|
29
|
+
}).catch(() => [] as readonly never[])
|
|
30
|
+
for (const e of entries) {
|
|
31
|
+
if (e.name.startsWith(".")) continue
|
|
32
|
+
if (e.name.includes(".uploading-")) continue
|
|
33
|
+
// Entry names always use `/` separators, mirroring zip
|
|
34
|
+
// entry names, regardless of the platform's separator.
|
|
35
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name
|
|
36
|
+
if (e.isDirectory()) {
|
|
37
|
+
await collect(join(current, e.name), rel)
|
|
38
|
+
} else if (e.isFile()) {
|
|
39
|
+
out.push(rel)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
await collect(".", "")
|
|
44
|
+
// An explicit order manifest wins when it validates against
|
|
45
|
+
// this listing; otherwise fall back to the natural name sort.
|
|
46
|
+
const manifest = await readOrderManifest(dir)
|
|
47
|
+
if (manifest !== undefined) {
|
|
48
|
+
const ordered = orderEntries(manifest, out)
|
|
49
|
+
if (ordered !== undefined) return ordered
|
|
50
|
+
}
|
|
51
|
+
return naturalSort(out)
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async readEntry(relPath: string): Promise<Buffer> {
|
|
55
|
+
const safe = await resolvePath(relPath)
|
|
56
|
+
const handle = await open(safe, "r")
|
|
57
|
+
try {
|
|
58
|
+
return await handle.readFile()
|
|
59
|
+
} finally {
|
|
60
|
+
await handle.close()
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async readEntrySlice(
|
|
65
|
+
relPath: string,
|
|
66
|
+
start: number,
|
|
67
|
+
end: number,
|
|
68
|
+
): Promise<Buffer> {
|
|
69
|
+
const safe = await resolvePath(relPath)
|
|
70
|
+
const handle = await open(safe, "r")
|
|
71
|
+
try {
|
|
72
|
+
const { size } = await handle.stat()
|
|
73
|
+
const clampedStart = Math.min(Math.max(0, start), size)
|
|
74
|
+
const clampedEnd = Math.min(Math.max(clampedStart, end), size)
|
|
75
|
+
const length = Math.max(0, clampedEnd - clampedStart)
|
|
76
|
+
if (length === 0) return Buffer.alloc(0)
|
|
77
|
+
const buf = Buffer.alloc(length)
|
|
78
|
+
await handle.read(buf, 0, length, clampedStart)
|
|
79
|
+
return buf
|
|
80
|
+
} finally {
|
|
81
|
+
await handle.close()
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async openEntryStream(relPath: string): Promise<{
|
|
86
|
+
readonly stream: Readable
|
|
87
|
+
readonly size: number
|
|
88
|
+
readonly mtimeMs?: number
|
|
89
|
+
readonly path?: string
|
|
90
|
+
}> {
|
|
91
|
+
const safe = await resolvePath(relPath)
|
|
92
|
+
const info = await stat(safe)
|
|
93
|
+
return {
|
|
94
|
+
stream: createReadStream(safe),
|
|
95
|
+
size: info.size,
|
|
96
|
+
mtimeMs: info.mtimeMs,
|
|
97
|
+
path: safe,
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async resolveByteRange(
|
|
102
|
+
relPath: string,
|
|
103
|
+
): Promise<{ readonly size: number } | undefined> {
|
|
104
|
+
const safe = await resolvePath(relPath)
|
|
105
|
+
const info = await stat(safe).catch(() => undefined)
|
|
106
|
+
return info === undefined ? undefined : { size: info.size }
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
async resolveSeekablePath(relPath: string): Promise<string | undefined> {
|
|
110
|
+
const safe = await resolvePath(relPath)
|
|
111
|
+
const info = await stat(safe).catch(() => undefined)
|
|
112
|
+
return info?.isFile() === true ? safe : undefined
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve a plugin-supplied relative path against an import directory,
|
|
119
|
+
* rejecting attempts to escape the directory or use absolute paths.
|
|
120
|
+
*/
|
|
121
|
+
export function resolveSafeImportPath(dir: string, relPath: string): string {
|
|
122
|
+
if (relPath.length === 0) {
|
|
123
|
+
throw new Error("path is empty")
|
|
124
|
+
}
|
|
125
|
+
if (relPath.includes("\0")) {
|
|
126
|
+
throw new Error("path contains null byte")
|
|
127
|
+
}
|
|
128
|
+
if (isAbsolute(relPath)) {
|
|
129
|
+
throw new Error("absolute paths are not allowed")
|
|
130
|
+
}
|
|
131
|
+
const normalized = normalize(relPath)
|
|
132
|
+
if (normalized.startsWith("..") || normalized === "..") {
|
|
133
|
+
throw new Error("path escapes import directory")
|
|
134
|
+
}
|
|
135
|
+
const root = resolve(dir)
|
|
136
|
+
const candidate = resolve(root, normalized)
|
|
137
|
+
if (candidate !== root && !candidate.startsWith(root + sep)) {
|
|
138
|
+
throw new Error("path escapes import directory")
|
|
139
|
+
}
|
|
140
|
+
return candidate
|
|
141
|
+
}
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
2
|
+
import { join, resolve } from "node:path"
|
|
3
|
+
import type { PluginManifest, PluginManifestId } from "@hoardodile/sdk-types"
|
|
4
|
+
import { pluginManifest as pluginManifestSchema } from "@hoardodile/sdk-types/schema"
|
|
5
|
+
import type { FoundPlugin, MissingPlugin } from "./api-types.ts"
|
|
6
|
+
import type { PluginSettingsStore } from "./settings-store.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default pin for plugins with no settings row yet (first install /
|
|
10
|
+
* clean state / builtin). Pinned plugins wear their name on resource
|
|
11
|
+
* cards so the type stays readable without a cover.
|
|
12
|
+
*/
|
|
13
|
+
const DEFAULT_PLUGIN_PINNED = true
|
|
14
|
+
|
|
15
|
+
/** Default first-install priority — reorderable from the UI afterwards. */
|
|
16
|
+
const DEFAULT_PLUGIN_PRIORITY = 100
|
|
17
|
+
|
|
18
|
+
export type PluginDiscoveryDeps = {
|
|
19
|
+
/** Directory of the mandatory builtin plugin (`manifest.json` at its root). */
|
|
20
|
+
readonly builtinDir?: string
|
|
21
|
+
/** Extra plugin directories for development (override same-id disk plugins). */
|
|
22
|
+
readonly devPluginDirs?: readonly string[]
|
|
23
|
+
/** Directory where installed plugins live, one subdirectory per plugin. */
|
|
24
|
+
readonly pluginsDir: string
|
|
25
|
+
/** DB-backed per-plugin settings (enabled/priority/pinned/color). */
|
|
26
|
+
readonly settings: PluginSettingsStore
|
|
27
|
+
/** Skip dev plugins entirely (e.g. production configuration). */
|
|
28
|
+
readonly disableDevPlugins?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Plugin discovery: finds plugins on disk (builtin, dev, installed),
|
|
33
|
+
* merges their DB settings, and reports which known plugins are missing.
|
|
34
|
+
*/
|
|
35
|
+
export type PluginDiscovery = {
|
|
36
|
+
readonly discover: () => Promise<{
|
|
37
|
+
found: FoundPlugin[]
|
|
38
|
+
missing: MissingPlugin[]
|
|
39
|
+
}>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create the discovery pipeline. Order matters: the builtin plugin wins
|
|
44
|
+
* priority, dev plugins override same-id installed ones, and anything in
|
|
45
|
+
* settings but absent from disk is reported missing.
|
|
46
|
+
*/
|
|
47
|
+
export function createPluginDiscovery(
|
|
48
|
+
deps: PluginDiscoveryDeps,
|
|
49
|
+
): PluginDiscovery {
|
|
50
|
+
// The first loadAll runs at boot: a broken builtin there is a config
|
|
51
|
+
// error that should fail fast. Later (re)scans degrade gracefully —
|
|
52
|
+
// the loader keeps the previous registry when discovery throws.
|
|
53
|
+
let firstLoad = true
|
|
54
|
+
|
|
55
|
+
async function discover(): Promise<{
|
|
56
|
+
found: FoundPlugin[]
|
|
57
|
+
missing: MissingPlugin[]
|
|
58
|
+
}> {
|
|
59
|
+
const found: FoundPlugin[] = []
|
|
60
|
+
// Every discovered id, across all sources. Guarantees each plugin
|
|
61
|
+
// id is activated exactly once — a duplicate would make activation
|
|
62
|
+
// dispose the first worker while the registry still references it
|
|
63
|
+
// (every hook then fails with "sandbox disposed").
|
|
64
|
+
const seen = new Set<PluginManifestId>()
|
|
65
|
+
|
|
66
|
+
// 1. Builtin plugin — its id is reserved; dev/disk copies are skipped.
|
|
67
|
+
if (deps.builtinDir !== undefined) {
|
|
68
|
+
const builtin = discoverBuiltin(deps.builtinDir, firstLoad)
|
|
69
|
+
if (builtin !== undefined) {
|
|
70
|
+
found.push(builtin)
|
|
71
|
+
seen.add(builtin.id)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
firstLoad = false
|
|
75
|
+
|
|
76
|
+
// 2. Dev plugins. Same-id dev dirs override each other (later wins,
|
|
77
|
+
// matching the dev-over-disk precedence); the earlier entry is
|
|
78
|
+
// replaced so activation never loads the same id twice.
|
|
79
|
+
const dev = discoverDevPlugins(
|
|
80
|
+
deps.devPluginDirs,
|
|
81
|
+
deps.settings,
|
|
82
|
+
deps.disableDevPlugins ?? false,
|
|
83
|
+
)
|
|
84
|
+
for (const plugin of dev) {
|
|
85
|
+
if (seen.has(plugin.id)) {
|
|
86
|
+
const index = found.findIndex((f) => f.id === plugin.id)
|
|
87
|
+
if (index !== -1 && found[index]?.source === "dev") {
|
|
88
|
+
// Duplicate dev dir — the later one wins.
|
|
89
|
+
found[index] = plugin
|
|
90
|
+
console.warn(
|
|
91
|
+
`[plugin-discovery] dev plugin ${plugin.id} (${plugin.manifest.name ?? plugin.id}) overrides an earlier dev dir entry`,
|
|
92
|
+
)
|
|
93
|
+
} else {
|
|
94
|
+
console.warn(
|
|
95
|
+
`[plugin-discovery] skipping dev plugin ${plugin.id}: id is reserved by the builtin plugin`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
found.push(plugin)
|
|
101
|
+
seen.add(plugin.id)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. Disk plugins, skipping ids overridden by dev or the builtin.
|
|
105
|
+
const disk = discoverDiskPlugins(deps.pluginsDir, deps.settings)
|
|
106
|
+
for (const plugin of disk) {
|
|
107
|
+
if (seen.has(plugin.id)) {
|
|
108
|
+
console.warn(
|
|
109
|
+
`[plugin-discovery] skipping disk plugin ${plugin.id}: id already registered (builtin or dev plugin)`,
|
|
110
|
+
)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
found.push(plugin)
|
|
114
|
+
seen.add(plugin.id)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 4. Missing plugins (settings-only)
|
|
118
|
+
const missing = discoverMissingPlugins(deps.settings, seen)
|
|
119
|
+
|
|
120
|
+
return { found, missing }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { discover }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function discoverBuiltin(
|
|
127
|
+
builtinDir: string,
|
|
128
|
+
failFast: boolean,
|
|
129
|
+
): FoundPlugin | undefined {
|
|
130
|
+
const resolved = resolve(builtinDir)
|
|
131
|
+
const manifest = parseManifest(resolved, "builtin")
|
|
132
|
+
if (manifest === undefined) {
|
|
133
|
+
const message =
|
|
134
|
+
`Builtin plugin not found or invalid at ${resolved}. ` +
|
|
135
|
+
"Set BUILTIN_PATH env to a valid plugin directory."
|
|
136
|
+
if (failFast) {
|
|
137
|
+
throw new Error(message)
|
|
138
|
+
}
|
|
139
|
+
console.error(`[plugin-discovery] ${message}`)
|
|
140
|
+
return undefined
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
id: manifest.id,
|
|
144
|
+
manifest,
|
|
145
|
+
diskPath: resolved,
|
|
146
|
+
source: "builtin",
|
|
147
|
+
enabled: true,
|
|
148
|
+
priority: Number.MAX_SAFE_INTEGER,
|
|
149
|
+
pinned: DEFAULT_PLUGIN_PINNED,
|
|
150
|
+
color: "",
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function discoverDevPlugins(
|
|
155
|
+
devPluginDirs: readonly string[] | undefined,
|
|
156
|
+
settings: PluginSettingsStore,
|
|
157
|
+
disableDevPlugins: boolean,
|
|
158
|
+
): FoundPlugin[] {
|
|
159
|
+
if (disableDevPlugins) {
|
|
160
|
+
console.info("[plugin-discovery] dev plugins disabled by configuration")
|
|
161
|
+
return []
|
|
162
|
+
}
|
|
163
|
+
if (devPluginDirs === undefined || devPluginDirs.length === 0) return []
|
|
164
|
+
|
|
165
|
+
const results: FoundPlugin[] = []
|
|
166
|
+
for (const dir of devPluginDirs) {
|
|
167
|
+
const resolved = resolve(dir)
|
|
168
|
+
const manifest = parseManifest(resolved, "dev")
|
|
169
|
+
if (manifest === undefined) continue
|
|
170
|
+
|
|
171
|
+
const row = settings.get(manifest.id)
|
|
172
|
+
|
|
173
|
+
results.push({
|
|
174
|
+
id: manifest.id,
|
|
175
|
+
manifest,
|
|
176
|
+
diskPath: resolved,
|
|
177
|
+
source: "dev",
|
|
178
|
+
enabled: true,
|
|
179
|
+
priority: row?.priority ?? DEFAULT_PLUGIN_PRIORITY,
|
|
180
|
+
pinned: row?.pinned ?? DEFAULT_PLUGIN_PINNED,
|
|
181
|
+
color: row?.color ?? "",
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
return results
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function discoverDiskPlugins(
|
|
188
|
+
pluginsDir: string,
|
|
189
|
+
settings: PluginSettingsStore,
|
|
190
|
+
): FoundPlugin[] {
|
|
191
|
+
if (!existsSync(pluginsDir)) return []
|
|
192
|
+
|
|
193
|
+
const dirents = readdirSync(pluginsDir, { withFileTypes: true })
|
|
194
|
+
if (dirents.length === 0) return []
|
|
195
|
+
|
|
196
|
+
const results: FoundPlugin[] = []
|
|
197
|
+
for (const dirent of dirents) {
|
|
198
|
+
if (!dirent.isDirectory()) continue
|
|
199
|
+
const dirPath = join(pluginsDir, dirent.name)
|
|
200
|
+
|
|
201
|
+
const manifest = parseManifest(dirPath, dirent.name)
|
|
202
|
+
if (manifest === undefined) continue
|
|
203
|
+
|
|
204
|
+
const row = settings.get(manifest.id)
|
|
205
|
+
const enabled = row?.enabled ?? true
|
|
206
|
+
const priority = row?.priority ?? DEFAULT_PLUGIN_PRIORITY
|
|
207
|
+
const pinned = row?.pinned ?? DEFAULT_PLUGIN_PINNED
|
|
208
|
+
const color = row?.color ?? ""
|
|
209
|
+
|
|
210
|
+
results.push({
|
|
211
|
+
id: manifest.id,
|
|
212
|
+
manifest,
|
|
213
|
+
diskPath: dirPath,
|
|
214
|
+
source: "disk",
|
|
215
|
+
enabled,
|
|
216
|
+
priority,
|
|
217
|
+
pinned,
|
|
218
|
+
color,
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
return results
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function discoverMissingPlugins(
|
|
225
|
+
settings: PluginSettingsStore,
|
|
226
|
+
loadedIds: ReadonlySet<PluginManifestId>,
|
|
227
|
+
): MissingPlugin[] {
|
|
228
|
+
const results: MissingPlugin[] = []
|
|
229
|
+
for (const row of settings.all()) {
|
|
230
|
+
if (loadedIds.has(row.id)) continue
|
|
231
|
+
let manifest: PluginManifest
|
|
232
|
+
try {
|
|
233
|
+
manifest = JSON.parse(row.manifest) as PluginManifest
|
|
234
|
+
} catch {
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
results.push({
|
|
238
|
+
id: row.id,
|
|
239
|
+
manifest,
|
|
240
|
+
enabled: row.enabled,
|
|
241
|
+
priority: row.priority,
|
|
242
|
+
pinned: row.pinned,
|
|
243
|
+
color: row.color,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
return results
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Read and validate `manifest.json` in `dirPath` against the zod
|
|
251
|
+
* contract. Logs a warning and returns `undefined` for every failure
|
|
252
|
+
* mode (missing file, unreadable, invalid JSON, schema violation) so
|
|
253
|
+
* callers can skip the directory without crashing the scan.
|
|
254
|
+
*/
|
|
255
|
+
export function parseManifest(
|
|
256
|
+
dirPath: string,
|
|
257
|
+
dirName: string,
|
|
258
|
+
): PluginManifest | undefined {
|
|
259
|
+
const manifestPath = join(dirPath, "manifest.json")
|
|
260
|
+
if (!existsSync(manifestPath)) {
|
|
261
|
+
console.warn(`[plugin-discovery] skipping ${dirName}: no manifest.json`)
|
|
262
|
+
return undefined
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let raw: string
|
|
266
|
+
try {
|
|
267
|
+
raw = readFileSync(manifestPath, "utf-8")
|
|
268
|
+
} catch {
|
|
269
|
+
console.warn(
|
|
270
|
+
`[plugin-discovery] skipping ${dirName}: cannot read manifest.json`,
|
|
271
|
+
)
|
|
272
|
+
return undefined
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let parsed: unknown
|
|
276
|
+
try {
|
|
277
|
+
parsed = JSON.parse(raw)
|
|
278
|
+
} catch {
|
|
279
|
+
console.warn(
|
|
280
|
+
`[plugin-discovery] skipping ${dirName}: manifest is not valid JSON`,
|
|
281
|
+
)
|
|
282
|
+
return undefined
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const result = pluginManifestSchema.safeParse(parsed)
|
|
286
|
+
if (!result.success) {
|
|
287
|
+
console.warn(
|
|
288
|
+
`[plugin-discovery] skipping ${dirName}: invalid manifest`,
|
|
289
|
+
result.error,
|
|
290
|
+
)
|
|
291
|
+
return undefined
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return result.data
|
|
295
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-local domain error. Mirrors the wire-format taxonomy of the app's
|
|
3
|
+
* domain errors (`@hoardodile/shared`) so errors raised here keep their
|
|
4
|
+
* code/kind/details across the package boundary — the server's error
|
|
5
|
+
* translation recognizes this shape structurally via `isDomainError`.
|
|
6
|
+
*
|
|
7
|
+
* The host deliberately keeps its dependency graph minimal (consts +
|
|
8
|
+
* sdk-types only) and does not depend on `@hoardodile/shared`; the
|
|
9
|
+
* mirror is kept in sync by the bridge test in
|
|
10
|
+
* `apps/server/src/infra/host-errors.test.ts`.
|
|
11
|
+
*/
|
|
12
|
+
export const domainErrorCodes = [
|
|
13
|
+
"NOT_FOUND",
|
|
14
|
+
"CONFLICT",
|
|
15
|
+
"VALIDATION",
|
|
16
|
+
"UNAUTHORIZED",
|
|
17
|
+
"FORBIDDEN",
|
|
18
|
+
"RATE_LIMITED",
|
|
19
|
+
"UNSUPPORTED",
|
|
20
|
+
"INTERNAL",
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
export type DomainErrorCode = (typeof domainErrorCodes)[number]
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Host-local domain error carrying a wire-compatible code/kind/details
|
|
27
|
+
* triple. Thrown by host services (containers, loaders, hooks) so the
|
|
28
|
+
* server can translate them into its own domain errors verbatim.
|
|
29
|
+
*/
|
|
30
|
+
export class DomainError extends Error {
|
|
31
|
+
readonly code: DomainErrorCode
|
|
32
|
+
readonly kind: string
|
|
33
|
+
readonly details?: Readonly<Record<string, unknown>>
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
code: DomainErrorCode,
|
|
37
|
+
kind: string,
|
|
38
|
+
message: string,
|
|
39
|
+
details?: Readonly<Record<string, unknown>>,
|
|
40
|
+
) {
|
|
41
|
+
super(message)
|
|
42
|
+
this.name = "DomainError"
|
|
43
|
+
this.code = code
|
|
44
|
+
this.kind = kind
|
|
45
|
+
if (details !== undefined) this.details = details
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Convenience constructor for `VALIDATION` errors. */
|
|
50
|
+
export function invalid(
|
|
51
|
+
kind: string,
|
|
52
|
+
message: string,
|
|
53
|
+
details?: Readonly<Record<string, unknown>>,
|
|
54
|
+
): DomainError {
|
|
55
|
+
return new DomainError("VALIDATION", kind, message, details)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Convenience constructor for `CONFLICT` errors. */
|
|
59
|
+
export function conflict(
|
|
60
|
+
kind: string,
|
|
61
|
+
message: string,
|
|
62
|
+
details?: Readonly<Record<string, unknown>>,
|
|
63
|
+
): DomainError {
|
|
64
|
+
return new DomainError("CONFLICT", kind, message, details)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Convenience constructor for `NOT_FOUND` errors. */
|
|
68
|
+
export function notFound(
|
|
69
|
+
kind: string,
|
|
70
|
+
message: string,
|
|
71
|
+
details?: Readonly<Record<string, unknown>>,
|
|
72
|
+
): DomainError {
|
|
73
|
+
return new DomainError("NOT_FOUND", kind, message, details)
|
|
74
|
+
}
|
package/src/fixtures.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ResourceAPI fixtures now live in `@hoardodile/sdk-types` — the
|
|
3
|
+
* shared contract (see `plugin-definition.ts` there). This module keeps
|
|
4
|
+
* the module path so internal imports stay untouched.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type { ResourceAPIFixtureConfig } from "@hoardodile/sdk-types"
|
|
8
|
+
export { createResourceAPIFixture, stubLogger } from "@hoardodile/sdk-types"
|