@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.
Files changed (144) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +73 -0
  3. package/dist/chunks/worker-entry.mjs +422 -0
  4. package/dist/container-fKxefO7p.d.ts +58 -0
  5. package/dist/contract/index.d.ts +49 -0
  6. package/dist/contract/index.js +187 -0
  7. package/dist/contract/index.js.map +1 -0
  8. package/dist/hoard/index.d.ts +754 -0
  9. package/dist/hoard/index.js +1939 -0
  10. package/dist/hoard/index.js.map +1 -0
  11. package/dist/image-Dx2Be3Bj.d.ts +168 -0
  12. package/dist/index.d.ts +788 -0
  13. package/dist/index.js +3311 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/media/index.d.ts +96 -0
  16. package/dist/media/index.js +152 -0
  17. package/dist/media/index.js.map +1 -0
  18. package/dist/probe/index.d.ts +113 -0
  19. package/dist/probe/index.js +454 -0
  20. package/dist/probe/index.js.map +1 -0
  21. package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
  22. package/dist/render/index.d.ts +125 -0
  23. package/dist/render/index.js +430 -0
  24. package/dist/render/index.js.map +1 -0
  25. package/package.json +103 -0
  26. package/src/__testutils__/zip-fixtures.ts +139 -0
  27. package/src/activation.ts +116 -0
  28. package/src/api-types.ts +60 -0
  29. package/src/api.test.ts +564 -0
  30. package/src/api.ts +501 -0
  31. package/src/archive/7z.test.ts +222 -0
  32. package/src/archive/7z.ts +261 -0
  33. package/src/archive/archive-ops.test.ts +654 -0
  34. package/src/archive/extract-archive.test.ts +237 -0
  35. package/src/archive/extract-archive.ts +534 -0
  36. package/src/archive/extract.ts +693 -0
  37. package/src/archive/format.ts +49 -0
  38. package/src/archive/index.ts +116 -0
  39. package/src/archive/listing.ts +121 -0
  40. package/src/archive/materialize.ts +88 -0
  41. package/src/archive/name-decode.test.ts +34 -0
  42. package/src/archive/name-decode.ts +287 -0
  43. package/src/archive/nested-cd-cache.ts +55 -0
  44. package/src/archive/nested-entry.test.ts +194 -0
  45. package/src/archive/nested-entry.ts +254 -0
  46. package/src/archive/pack.ts +40 -0
  47. package/src/archive/zip-entries.test.ts +56 -0
  48. package/src/archive/zip-entries.ts +336 -0
  49. package/src/capability-guard.ts +73 -0
  50. package/src/container.ts +62 -0
  51. package/src/contract/container-fixture.ts +74 -0
  52. package/src/contract/index.ts +10 -0
  53. package/src/contract/suite.test.ts +101 -0
  54. package/src/contract/suite.ts +205 -0
  55. package/src/define-plugin.test.ts +53 -0
  56. package/src/define-plugin.ts +12 -0
  57. package/src/dev-runner.test.ts +98 -0
  58. package/src/dev-runner.ts +73 -0
  59. package/src/directory-api.test.ts +229 -0
  60. package/src/directory-api.ts +42 -0
  61. package/src/directory-container.ts +141 -0
  62. package/src/discovery.ts +295 -0
  63. package/src/errors.ts +74 -0
  64. package/src/fixtures.ts +8 -0
  65. package/src/hash.test.ts +301 -0
  66. package/src/hash.ts +167 -0
  67. package/src/hoard/dir-size.ts +58 -0
  68. package/src/hoard/index.ts +84 -0
  69. package/src/hoard/migrate-script.test.ts +539 -0
  70. package/src/hoard/order-manifest.test.ts +115 -0
  71. package/src/hoard/order-manifest.ts +131 -0
  72. package/src/hoard/paths.test.ts +226 -0
  73. package/src/hoard/paths.ts +569 -0
  74. package/src/hoard/plugin-vault.test.ts +160 -0
  75. package/src/hoard/plugin-vault.ts +233 -0
  76. package/src/hoard/sanitize.test.ts +149 -0
  77. package/src/hoard/sanitize.ts +230 -0
  78. package/src/hoard/staging-dir.ts +133 -0
  79. package/src/hoard/version.test.ts +285 -0
  80. package/src/hoard/version.ts +216 -0
  81. package/src/hoard/versioned-folder-ops.ts +152 -0
  82. package/src/hoard/write-versioned.test.ts +67 -0
  83. package/src/hoard/write-versioned.ts +39 -0
  84. package/src/hooks.test.ts +528 -0
  85. package/src/hooks.ts +428 -0
  86. package/src/index.ts +135 -0
  87. package/src/loader.test.ts +442 -0
  88. package/src/loader.ts +213 -0
  89. package/src/media/index.ts +16 -0
  90. package/src/media/render-cache.ts +27 -0
  91. package/src/media/seekable.ts +35 -0
  92. package/src/media/thumb-input.test.ts +109 -0
  93. package/src/media/thumb-input.ts +194 -0
  94. package/src/nested-view.test.ts +223 -0
  95. package/src/nested-view.ts +239 -0
  96. package/src/probe/audio.test.ts +135 -0
  97. package/src/probe/audio.ts +140 -0
  98. package/src/probe/av.ts +84 -0
  99. package/src/probe/ffprobe.ts +197 -0
  100. package/src/probe/image.test.ts +251 -0
  101. package/src/probe/image.ts +336 -0
  102. package/src/probe/index.ts +54 -0
  103. package/src/probe/sniff.test.ts +72 -0
  104. package/src/probe/sniff.ts +67 -0
  105. package/src/probe/video.test.ts +41 -0
  106. package/src/probe/video.ts +116 -0
  107. package/src/probe-cache.test.ts +89 -0
  108. package/src/probe-cache.ts +65 -0
  109. package/src/render/ffmpeg.ts +78 -0
  110. package/src/render/index.ts +34 -0
  111. package/src/render/pipeline.test.ts +187 -0
  112. package/src/render/pipeline.ts +479 -0
  113. package/src/sandbox/call-timers.ts +68 -0
  114. package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
  115. package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
  116. package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
  117. package/src/sandbox/fixtures/container-plugin.mjs +8 -0
  118. package/src/sandbox/fixtures/context-plugin.mjs +19 -0
  119. package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
  120. package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
  121. package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
  122. package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
  123. package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
  124. package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
  125. package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
  126. package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
  127. package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
  128. package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
  129. package/src/sandbox/fixtures/range-plugin.mjs +9 -0
  130. package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
  131. package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
  132. package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
  133. package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
  134. package/src/sandbox/host.test.ts +619 -0
  135. package/src/sandbox/host.ts +933 -0
  136. package/src/sandbox/host.unit.test.ts +330 -0
  137. package/src/sandbox/protocol.test.ts +85 -0
  138. package/src/sandbox/protocol.ts +147 -0
  139. package/src/sandbox/sandboxed-plugin.ts +30 -0
  140. package/src/sandbox/worker-entry.mjs +422 -0
  141. package/src/seed.test.ts +102 -0
  142. package/src/seed.ts +160 -0
  143. package/src/settings-store.ts +26 -0
  144. 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
+ }
@@ -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
+ }
@@ -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"