@hoardodile/host 0.1.2 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoardodile/host",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "license": "MIT",
5
5
  "description": "hoardodile plugin runtime host: sandbox, hook strategy, ResourceAPI, containers and the test runner.",
6
6
  "keywords": [
@@ -67,7 +67,7 @@
67
67
  "file-type": "^22.0.2",
68
68
  "yauzl": "^3.4.0",
69
69
  "yazl": "^3.3.1",
70
- "@hoardodile/sdk-types": "0.1.2"
70
+ "@hoardodile/sdk-types": "0.1.3"
71
71
  },
72
72
  "optionalDependencies": {
73
73
  "@derhuerst/ffprobe-static": "^5.3.0",
@@ -75,7 +75,10 @@ export {
75
75
  versionedPath,
76
76
  writeActiveVersion,
77
77
  } from "./version.ts"
78
- export type { VersionedFolderOps } from "./versioned-folder-ops.ts"
78
+ export type {
79
+ VersionedFolderOps,
80
+ VersionedFolderSubjectKind,
81
+ } from "./versioned-folder-ops.ts"
79
82
  export {
80
83
  archiveStaleFiles,
81
84
  buildVersionedFolderOps,
@@ -71,7 +71,8 @@ function hasForbiddenVisibleChar(segment: string): boolean {
71
71
  * per-version database snapshot), `db-backups/` (manual backups,
72
72
  * only kept for the current version), `snapshots/` (automatic daily
73
73
  * snapshots, only kept for the current version), `resources/<id>/`,
74
- * `characters/<id>/`, `plugins/<id>/` (installed content plugins
74
+ * `characters/<id>/`, `tags/<id>/` (tag art — see {@link VersionPaths.tag}),
75
+ * `plugins/<id>/` (installed content plugins
75
76
  * frozen with that version; the builtin `file` plugin is not stored
76
77
  * here). Old versions are FROZEN: no writes ever land in
77
78
  * `versions/<v>` once a `versions/<v+1>` exists.
@@ -129,10 +130,18 @@ export type VersionPaths = {
129
130
  resources(): string
130
131
  /** Root folder of all characters in this version: `<root>/versions/<v>/characters`. */
131
132
  characters(): string
133
+ /** Root folder of all tags in this version: `<root>/versions/<v>/tags`. */
134
+ tags(): string
132
135
  /** Root folder of all documents in this version: `<root>/versions/<v>/documents`. */
133
136
  documents(): string
134
137
  /** Root folder of a character: `<root>/versions/<v>/characters/<id>`. */
135
138
  character(id: string): string
139
+ /**
140
+ * Root folder of a tag: `<root>/versions/<v>/tags/<id>`. Holds the
141
+ * tag's single image slot (`image.<ext>`), the same convention as
142
+ * character avatar/fullbody slots.
143
+ */
144
+ tag(id: string): string
136
145
  /** Root of manual backups: `<root>/versions/<v>/db-backups`. */
137
146
  dbBackups(): string
138
147
  /** Path to one manual backup: `<root>/versions/<v>/db-backups/<name>`. */
@@ -147,7 +156,7 @@ export type VersionPaths = {
147
156
  * delete cannot remove a folder whose files live under frozen past
148
157
  * archives.
149
158
  */
150
- deletedMarker(kind: "resources" | "characters", id: string): string
159
+ deletedMarker(kind: "resources" | "characters" | "tags", id: string): string
151
160
  /** Root folder of a document: `<root>/versions/<v>/documents/<id>`. */
152
161
  document(id: string): string
153
162
  /**
@@ -175,12 +184,12 @@ export type LocalPaths = {
175
184
  logs(): string
176
185
  /**
177
186
  * Path to a local derived cover/thumb variant:
178
- * `<localRoot>/cache/<resources|characters>/<id>/<variant>.<format>`.
187
+ * `<localRoot>/cache/<resources|characters|tags>/<id>/<variant>.<format>`.
179
188
  * Holds synthesized covers (resource covers, character avatars and
180
- * fullbody images); re-rendered when cleared.
189
+ * fullbody images, tag art); re-rendered when cleared.
181
190
  */
182
191
  localCover(
183
- subjectKind: "resource" | "character",
192
+ subjectKind: "resource" | "character" | "tag",
184
193
  id: string,
185
194
  variant: string,
186
195
  format?: string,
@@ -215,6 +224,13 @@ export type LocalPaths = {
215
224
  * (b) thumbnail variants (`avatar.webp`, `fullbody.webp`).
216
225
  */
217
226
  character(id: string): string
227
+ /**
228
+ * Root of the local per-tag directory:
229
+ * `<localRoot>/cache/tags/<id>`.
230
+ * Holds (a) versioned copies of replaced tag art and (b) the tag
231
+ * thumbnail variant (`image.avif`).
232
+ */
233
+ tag(id: string): string
218
234
  /** Root of the trash: `<localRoot>/trash`. */
219
235
  trash(): string
220
236
  /** Path to a single trashed item: `<localRoot>/trash/<id>`. */
@@ -356,8 +372,10 @@ export function createStoragePaths(
356
372
  join(vRoot, "resources", assertSafeSegment(id), RESOURCE_DATA_DIR_NAME),
357
373
  resources: () => join(vRoot, "resources"),
358
374
  characters: () => join(vRoot, "characters"),
375
+ tags: () => join(vRoot, "tags"),
359
376
  documents: () => join(vRoot, "documents"),
360
377
  character: (id) => join(vRoot, "characters", assertSafeSegment(id)),
378
+ tag: (id) => join(vRoot, "tags", assertSafeSegment(id)),
361
379
  dbBackups: () => join(vRoot, "db-backups"),
362
380
  dbBackup: (name) => join(vRoot, "db-backups", assertSafeSegment(name)),
363
381
  snapshots: () => join(vRoot, "snapshots"),
@@ -401,6 +419,7 @@ export function createStoragePaths(
401
419
  join(cacheRoot, "resources", assertSafeSegment(id), "files-cache.json"),
402
420
  resource: (id) => join(cacheRoot, "resources", assertSafeSegment(id)),
403
421
  character: (id) => join(cacheRoot, "characters", assertSafeSegment(id)),
422
+ tag: (id) => join(cacheRoot, "tags", assertSafeSegment(id)),
404
423
  trash: () => join(localRoot, "trash"),
405
424
  trashItem: (id) => join(localRoot, "trash", assertSafeSegment(id)),
406
425
  tmp: () => join(cacheRoot, "tmp"),
@@ -465,13 +484,20 @@ export function createStoragePaths(
465
484
  }
466
485
 
467
486
  /**
468
- * Map a {@link LocalPaths.thumb} subjectKind onto its on-disk subdirectory.
469
- * Variants now live flat inside the per-id local directory (no enclosing
470
- * `thumbs/` parent), so `resource` -> `resources` and `character` ->
471
- * `characters` (both plural to match the storage layout convention).
487
+ * Map a {@link LocalPaths.localCover} subjectKind onto its on-disk
488
+ * subdirectory. Variants now live flat inside the per-id local directory
489
+ * (no enclosing `thumbs/` parent), so `resource` -> `resources`,
490
+ * `character` -> `characters`, `tag` -> `tags` (all plural to match the
491
+ * storage layout convention).
472
492
  */
473
- function localCoverSubjectDir(subjectKind: "resource" | "character"): string {
474
- return subjectKind === "resource" ? "resources" : "characters"
493
+ function localCoverSubjectDir(
494
+ subjectKind: "resource" | "character" | "tag",
495
+ ): string {
496
+ return subjectKind === "resource"
497
+ ? "resources"
498
+ : subjectKind === "character"
499
+ ? "characters"
500
+ : "tags"
475
501
  }
476
502
 
477
503
  /**
@@ -3,6 +3,8 @@ import { extname, join } from "node:path"
3
3
  import type { StoragePaths } from "./paths.ts"
4
4
  import { writeVersioned } from "./write-versioned.ts"
5
5
 
6
+ export type VersionedFolderSubjectKind = "resource" | "character" | "tag"
7
+
6
8
  export type VersionedFolderOps = {
7
9
  /** Ensure the current-version entity folder exists. */
8
10
  ensureFolder(id: string): Promise<void>
@@ -24,7 +26,7 @@ export type VersionedFolderOps = {
24
26
  }
25
27
 
26
28
  /**
27
- * The four lifecycle operations shared by the resource and character
29
+ * The four lifecycle operations shared by the resource, character and tag
28
30
  * file-system layers. They differ only in which versioned folder they
29
31
  * target and how the trash / placeholder names are derived.
30
32
  *
@@ -35,17 +37,38 @@ export type VersionedFolderOps = {
35
37
  * `readOnly` is a live `{ current: boolean }` ref (the server's runtime
36
38
  * read-only flag), so a version switch mid-request re-reads it.
37
39
  */
40
+ const KIND_LAYOUT: Record<
41
+ VersionedFolderSubjectKind,
42
+ {
43
+ readonly trashPrefix: string
44
+ readonly deletedKind: "resources" | "characters" | "tags"
45
+ }
46
+ > = {
47
+ resource: { trashPrefix: "resources-", deletedKind: "resources" },
48
+ character: { trashPrefix: "characters-", deletedKind: "characters" },
49
+ tag: { trashPrefix: "tags-", deletedKind: "tags" },
50
+ }
51
+
38
52
  export function buildVersionedFolderOps(
39
53
  paths: StoragePaths,
40
54
  readOnly: { readonly current: boolean },
41
- kind: "resource" | "character",
55
+ kind: VersionedFolderSubjectKind,
42
56
  ): VersionedFolderOps {
43
57
  const folderOf =
44
58
  (id: string) =>
45
- (current: StoragePaths["latest"]): string =>
46
- kind === "resource" ? current.resource(id) : current.character(id)
47
- const trashPrefix = kind === "resource" ? "resources-" : "characters-"
48
- const deletedKind = kind === "resource" ? "resources" : "characters"
59
+ (current: StoragePaths["latest"]): string => {
60
+ switch (kind) {
61
+ case "resource":
62
+ return current.resource(id)
63
+ case "character":
64
+ return current.character(id)
65
+ case "tag":
66
+ return current.tag(id)
67
+ }
68
+ }
69
+ const layout = KIND_LAYOUT[kind]
70
+ const trashPrefix = layout.trashPrefix
71
+ const deletedKind = layout.deletedKind
49
72
 
50
73
  async function ensureFolder(id: string): Promise<void> {
51
74
  await writeVersioned(paths, readOnly.current, (current) =>
package/src/hooks.test.ts CHANGED
@@ -526,3 +526,73 @@ describe("plugin hooks: imageHashes", () => {
526
526
  ).resolves.toBeUndefined()
527
527
  })
528
528
  })
529
+
530
+ describe("runInstallHook", () => {
531
+ const installEntry = (onInstall: PluginDefinition["onInstall"]) => ({
532
+ id: PAGES_ID,
533
+ manifest: manifestFor(PAGES_ID, "Pages"),
534
+ enabled: true,
535
+ priority: 50,
536
+ pinned: false,
537
+ color: "",
538
+ missing: false,
539
+ builtin: false,
540
+ dev: false,
541
+ plugin: { ...createPagesPlugin(), onInstall },
542
+ })
543
+
544
+ test("invokes onInstall with an install-scoped API (empty file surface)", async () => {
545
+ const seen: ResourceAPI[] = []
546
+ const hooks = createPluginHooks({
547
+ getRegistry: () =>
548
+ buildRegistry([
549
+ installEntry(async (api) => {
550
+ seen.push(api)
551
+ }),
552
+ ]),
553
+ })
554
+
555
+ await hooks.runInstallHook(PAGES_ID)
556
+
557
+ expect(seen).toHaveLength(1)
558
+ expect(await seen[0]!.listFileNames()).toEqual([])
559
+ expect(seen[0]!.context.detect).toBeUndefined()
560
+ })
561
+
562
+ test("no-ops when the plugin declares no onInstall", async () => {
563
+ const hooks = createPluginHooks({
564
+ getRegistry: () => buildRegistry([installEntry(undefined)]),
565
+ })
566
+
567
+ await expect(hooks.runInstallHook(PAGES_ID)).resolves.toBeUndefined()
568
+ })
569
+
570
+ test("a throwing hook is logged and swallowed", async () => {
571
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
572
+ try {
573
+ const hooks = createPluginHooks({
574
+ getRegistry: () =>
575
+ buildRegistry([
576
+ installEntry(async () => {
577
+ throw new Error("boom")
578
+ }),
579
+ ]),
580
+ })
581
+
582
+ await expect(hooks.runInstallHook(PAGES_ID)).resolves.toBeUndefined()
583
+ expect(warn).toHaveBeenCalledWith(
584
+ expect.stringContaining("onInstall failed for plugin"),
585
+ )
586
+ } finally {
587
+ warn.mockRestore()
588
+ }
589
+ })
590
+
591
+ test("an unknown plugin id is a no-op", async () => {
592
+ const hooks = createPluginHooks({
593
+ getRegistry: () => buildRegistry([]),
594
+ })
595
+
596
+ await expect(hooks.runInstallHook(PAGES_ID)).resolves.toBeUndefined()
597
+ })
598
+ })
package/src/hooks.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  } from "@hoardodile/sdk-types"
7
7
  import type { PluginRegistry, PluginRegistryEntry } from "./api-types.ts"
8
8
  import { createCapabilityGuard } from "./capability-guard.ts"
9
+ import { createInstallScopeApi } from "./install-api.ts"
9
10
  import type { Detection, ResourceAPI } from "./types.ts"
10
11
 
11
12
  /** Absolute cap of hash rows one resource may contribute (host policy). */
@@ -161,6 +162,18 @@ export type PluginHooks = {
161
162
  api: ResourceAPI,
162
163
  pluginId: PluginManifestId,
163
164
  ) => Promise<ImageHashesResult | undefined>
165
+ /**
166
+ * Run the plugin's optional `onInstall` hook — best-effort, invoked
167
+ * by the host after a successful install/update commit (marketplace
168
+ * install/update or a zip upload; never seed/dev plugins). The hook
169
+ * receives an install-scoped {@link ResourceAPI}: no resource is
170
+ * attached (the file surface answers empty, `context.detect` is
171
+ * `undefined`), while the asset methods still work and stay gated by
172
+ * the shared consent dialog. A throwing (or consent-denied) hook is
173
+ * logged via `hookFailureText` and swallowed — the install itself is
174
+ * never failed; plugins must re-check at runtime.
175
+ */
176
+ readonly runInstallHook: (pluginId: PluginManifestId) => Promise<void>
164
177
  }
165
178
 
166
179
  export function createPluginHooks(deps: PluginHooksDeps): PluginHooks {
@@ -364,6 +377,18 @@ export function createPluginHooks(deps: PluginHooksDeps): PluginHooks {
364
377
  )
365
378
  }
366
379
 
380
+ async function runInstallHook(pluginId: PluginManifestId): Promise<void> {
381
+ const entry = getRegistry().getById(pluginId)
382
+ if (entry === undefined) return
383
+ await invokeHook(
384
+ entry,
385
+ entry.plugin.onInstall,
386
+ createInstallScopeApi(),
387
+ "onInstall",
388
+ "warn",
389
+ )
390
+ }
391
+
367
392
  return {
368
393
  defaultPluginId,
369
394
  getEffectiveEntry,
@@ -376,6 +401,7 @@ export function createPluginHooks(deps: PluginHooksDeps): PluginHooks {
376
401
  runMetaHooks,
377
402
  supportsImageHashes,
378
403
  runImageHashes,
404
+ runInstallHook,
379
405
  }
380
406
  }
381
407
 
@@ -0,0 +1,68 @@
1
+ import { pluginAssetError } from "@hoardodile/sdk-types"
2
+ import type { ResourceAPI } from "./types.ts"
3
+
4
+ /**
5
+ * The ResourceAPI handed to a plugin's `onInstall` hook: there is no
6
+ * resource attached, so the file surface answers empty (or throws a
7
+ * clear "no resource" error) and `context.detect` is `undefined`.
8
+ *
9
+ * The asset surface stays honest: in the app server the sandbox
10
+ * intercepts these methods before the API object is consulted and
11
+ * routes them to the consent-gated asset service with the owning
12
+ * plugin id — the onInstall download flow is identical to the runtime
13
+ * one. In-process hosts (fixtures, dev runner, CLI) answer
14
+ * `UNAVAILABLE` exactly like every other host without a consent
15
+ * channel.
16
+ */
17
+ export function createInstallScopeApi(): ResourceAPI {
18
+ const noResource = (method: string): Error =>
19
+ new Error(
20
+ `${method}() — no resource is attached to the onInstall hook; use the asset methods (download/statAsset/readAsset/deleteAsset) for install-time work`,
21
+ )
22
+ return {
23
+ logInfo() {},
24
+ logWarn() {},
25
+ logError() {},
26
+ context: { detect: undefined },
27
+ listFileNames: async () => [],
28
+ readFile: async () => {
29
+ throw noResource("readFile")
30
+ },
31
+ statFile: async () => undefined,
32
+ statFiles: async (paths) => paths.map(() => undefined),
33
+ sniff: async () => undefined,
34
+ probe: async () => ({ kind: "unknown", reason: "unavailable" }),
35
+ hashBytes: async () => {
36
+ throw noResource("hashBytes")
37
+ },
38
+ computeImageHashes: async () => undefined,
39
+ listContainer: async () => {
40
+ throw noResource("listContainer")
41
+ },
42
+ extractArchive: async () => {
43
+ throw noResource("extractArchive")
44
+ },
45
+ download: async () => {
46
+ throw pluginAssetError(
47
+ "UNAVAILABLE",
48
+ "download() — this host has no plugin asset service; only the app server host can download into the plugin vault",
49
+ )
50
+ },
51
+ statAsset: async () => {
52
+ throw unavailableAsset("statAsset")
53
+ },
54
+ readAsset: async () => {
55
+ throw unavailableAsset("readAsset")
56
+ },
57
+ deleteAsset: async () => {
58
+ throw unavailableAsset("deleteAsset")
59
+ },
60
+ }
61
+ }
62
+
63
+ function unavailableAsset(method: string): Error {
64
+ return pluginAssetError(
65
+ "UNAVAILABLE",
66
+ `${method}() — this host has no plugin asset vault; only the app server host manages vault files`,
67
+ )
68
+ }
@@ -337,7 +337,14 @@ export function createPluginSandbox(
337
337
  await teardownChild(previous)
338
338
  }
339
339
  if (!opts.eager) {
340
- void teardownChild(state)
340
+ // AWAITED, not fire-and-forget: the hook list was probed by
341
+ // this load, and the loader returns the sandboxed definition
342
+ // the moment we resolve. A caller that invokes a hook
343
+ // immediately after (e.g. the post-install `onInstall`) must
344
+ // not find a half-alive child: `ensureLoaded` would see the
345
+ // not-yet-torn-down child and post its invocation into a
346
+ // dying process (rejected as "worker stopped").
347
+ await teardownChild(state)
341
348
  }
342
349
  return createSandboxedPlugin(state.hooks ?? ["detect"], (hook, api) =>
343
350
  invoke(state, hook, api),
@@ -611,6 +618,28 @@ export function createPluginSandbox(
611
618
  throw new Error(`plugin ${state.id} sandbox unavailable`)
612
619
  }
613
620
 
621
+ try {
622
+ return await invokeOnChild(state, child, hook, api)
623
+ } finally {
624
+ // An install hook is one-shot and the host may immediately
625
+ // re-commit the same plugin (an update): a kept-alive worker
626
+ // holding the plugin directory (Windows keeps directory ops
627
+ // pinned while a child is alive) would make the vault-move
628
+ // during the next commit fail with EPERM. Drop the child right
629
+ // after an `onInstall`; the next invocation respawns it.
630
+ if (hook === "onInstall") {
631
+ state.respawnTimes = []
632
+ await teardownChild(state)
633
+ }
634
+ }
635
+ }
636
+
637
+ function invokeOnChild(
638
+ state: PluginState,
639
+ child: ChildProcess,
640
+ hook: HookName,
641
+ api: ResourceAPI,
642
+ ): Promise<unknown> {
614
643
  const callId = nextCallId++
615
644
  return new Promise((resolveCall, reject) => {
616
645
  const call: PendingCall = {
@@ -43,6 +43,7 @@ const HOOK_NAMES = [
43
43
  "coverLocal",
44
44
  "listFiles",
45
45
  "imageHashes",
46
+ "onInstall",
46
47
  ]
47
48
 
48
49
  const API_METHOD_NAMES = [
@@ -283,7 +284,11 @@ function send(message) {
283
284
  function approxByteSize(value) {
284
285
  if (value instanceof Uint8Array) return value.byteLength
285
286
  try {
286
- return JSON.stringify(value).length * 2
287
+ // `undefined` (hooks that legitimately return nothing — e.g.
288
+ // `onInstall`) stringifies to `undefined`, not a string: an empty
289
+ // payload must size 0, never throw into the Infinity branch.
290
+ const text = JSON.stringify(value)
291
+ return (typeof text === "string" ? text : "").length * 2
287
292
  } catch {
288
293
  return Infinity
289
294
  }