@hoardodile/host 0.1.1 → 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.
@@ -0,0 +1,35 @@
1
+ import { Readable } from 'node:stream';
2
+
3
+ /**
4
+ * On-the-fly zip exports. Resource sources are stored as bare files on
5
+ * disk — nothing is packed at commit time; this module streams STORED
6
+ * zip bytes straight to the consumer without a staging file.
7
+ */
8
+ /**
9
+ * A logical zip entry whose bytes are produced on demand. Streams are
10
+ * read once, in order, when the output is consumed.
11
+ */
12
+ type ZipStreamEntry = {
13
+ readonly name: string;
14
+ readonly size: number;
15
+ readonly openStream: () => Readable;
16
+ };
17
+ /**
18
+ * Stream a STORED zip from an ordered list of logical entries. Used by
19
+ * the HTTP layer for exports (resource source packs, bulk downloads)
20
+ * without a staging file on disk. Zero-length entries are packed as
21
+ * empty buffers.
22
+ */
23
+ declare function streamStoredZip(entries: readonly ZipStreamEntry[]): NodeJS.ReadableStream;
24
+ /**
25
+ * Pack the *contents* of `srcDir` (its entries at the archive root) into
26
+ * a deflated zip written to `zipPath`. Entry names use forward slashes,
27
+ * the file order is sorted by relative path, and the output is streamed
28
+ * to disk — deterministic on every platform, memory-bounded regardless
29
+ * of dist size. The canonical packer for project-internal zips (the
30
+ * plugin release artifact via the CLI); resource exports use
31
+ * {@link streamStoredZip} instead.
32
+ */
33
+ declare function packZipDirectory(srcDir: string, zipPath: string): Promise<void>;
34
+
35
+ export { type ZipStreamEntry as Z, packZipDirectory as p, streamStoredZip as s };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoardodile/host",
3
- "version": "0.1.1",
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.1"
70
+ "@hoardodile/sdk-types": "0.1.3"
71
71
  },
72
72
  "optionalDependencies": {
73
73
  "@derhuerst/ffprobe-static": "^5.3.0",
@@ -14,7 +14,7 @@ import {
14
14
  normalizeExtractedTree,
15
15
  } from "./extract.ts"
16
16
  import { listArchiveEntries, validateArchiveBudget } from "./listing.ts"
17
- import { streamStoredZip } from "./pack.ts"
17
+ import { packZipDirectory, streamStoredZip } from "./pack.ts"
18
18
  import {
19
19
  createFileArchiveSource,
20
20
  listZipEntries,
@@ -232,6 +232,30 @@ describe("extractArchiveInto", () => {
232
232
  ).rejects.toMatchObject({ kind: "resource.archive_open_failed" })
233
233
  })
234
234
 
235
+ test("rejects formats outside the allow-list without touching the destination", async () => {
236
+ const root = tempRoot()
237
+ const dest = join(root, "out")
238
+ await expect(
239
+ extractArchiveInto(
240
+ Readable.from(await buildZip([["a.txt", "alpha"]])),
241
+ dest,
242
+ { maxBytes: 1_000_000, formats: ["tar"] },
243
+ ),
244
+ ).rejects.toMatchObject({ kind: "resource.archive_format_not_allowed" })
245
+ expect(existsSync(join(dest, "a.txt"))).toBe(false)
246
+ })
247
+
248
+ test("accepts a zip under a zip-only allow-list", async () => {
249
+ const root = tempRoot()
250
+ const dest = join(root, "out")
251
+ await extractArchiveInto(
252
+ Readable.from(await buildZip([["a.txt", "alpha"]])),
253
+ dest,
254
+ { maxBytes: 1_000_000, formats: ["zip"] },
255
+ )
256
+ expect(await readFile(join(dest, "a.txt"), "utf8")).toBe("alpha")
257
+ })
258
+
235
259
  test("rejects a corrupt zip with the archive-open error", async () => {
236
260
  const root = tempRoot()
237
261
  const dest = join(root, "out")
@@ -575,6 +599,30 @@ describe("streamStoredZip", () => {
575
599
  })
576
600
  })
577
601
 
602
+ describe("packZipDirectory", () => {
603
+ test("packs a directory's contents at the zip root, sorted, with forward slashes", async () => {
604
+ const root = tempRoot()
605
+ const src = join(root, "src")
606
+ await mkdir(join(src, "nested"), { recursive: true })
607
+ await writeFile(join(src, "manifest.json"), '{"id":"x"}')
608
+ await writeFile(join(src, "a.txt"), "a")
609
+ await writeFile(join(src, "nested", "b.txt"), "b")
610
+ const zipPath = join(root, "out.zip")
611
+
612
+ await packZipDirectory(src, zipPath)
613
+
614
+ const entries = await listZipEntries(zipPath)
615
+ expect(entries.map((entry) => entry.name)).toEqual([
616
+ "a.txt",
617
+ "manifest.json",
618
+ "nested/b.txt",
619
+ ])
620
+ expect(await readEntryContent(zipPath, "nested/b.txt")).toEqual(
621
+ Buffer.from("b"),
622
+ )
623
+ })
624
+ })
625
+
578
626
  describe("normalizeExtractedTree", () => {
579
627
  test("renames macOS %XX-escaped legacy names to the decoded listing name", async () => {
580
628
  const root = tempRoot()
@@ -66,6 +66,14 @@ export type ExtractArchiveOptions = {
66
66
  /** Optional entry-count budget (enforced on the listing). */
67
67
  readonly maxEntries?: number
68
68
  readonly onProgress?: ZipExtractReporter
69
+ /**
70
+ * Optional format allow-list (e.g. `["zip"]` for plugin installs).
71
+ * An archive sniffed outside the list is rejected up front with
72
+ * `resource.archive_format_not_allowed` — project-internal channels
73
+ * that only ever produce one format stop admitting the others
74
+ * instead of opening every codec to untrusted input.
75
+ */
76
+ readonly formats?: readonly ContainerFormat[]
69
77
  }
70
78
 
71
79
  /**
@@ -100,6 +108,13 @@ export async function extractArchiveInto(
100
108
  {},
101
109
  )
102
110
  }
111
+ if (opts.formats !== undefined && !opts.formats.includes(format)) {
112
+ throw invalid(
113
+ "resource.archive_format_not_allowed",
114
+ `archive format ${format} is not allowed here (allowed: ${opts.formats.join(", ")})`,
115
+ { allowed: opts.formats },
116
+ )
117
+ }
103
118
  if (format === "zip" && resolveSevenZipPath() === undefined) {
104
119
  return extractZipBuffer(buffer, destDir, opts)
105
120
  }
@@ -102,7 +102,7 @@ export {
102
102
  VIRTUAL_PATH_SEPARATOR,
103
103
  } from "./nested-entry.ts"
104
104
  export type { ZipStreamEntry } from "./pack.ts"
105
- export { streamStoredZip } from "./pack.ts"
105
+ export { packZipDirectory, streamStoredZip } from "./pack.ts"
106
106
  export type { ArchiveSource, ZipEntry } from "./zip-entries.ts"
107
107
  export {
108
108
  createFileArchiveSource,
@@ -1,4 +1,7 @@
1
+ import { createWriteStream, readdirSync } from "node:fs"
2
+ import { join, resolve, sep } from "node:path"
1
3
  import type { Readable } from "node:stream"
4
+ import { pipeline } from "node:stream/promises"
2
5
  import yazl from "yazl"
3
6
 
4
7
  /**
@@ -38,3 +41,50 @@ export function streamStoredZip(entries: readonly ZipStreamEntry[]) {
38
41
  zip.end()
39
42
  return zip.outputStream
40
43
  }
44
+
45
+ /**
46
+ * Pack the *contents* of `srcDir` (its entries at the archive root) into
47
+ * a deflated zip written to `zipPath`. Entry names use forward slashes,
48
+ * the file order is sorted by relative path, and the output is streamed
49
+ * to disk — deterministic on every platform, memory-bounded regardless
50
+ * of dist size. The canonical packer for project-internal zips (the
51
+ * plugin release artifact via the CLI); resource exports use
52
+ * {@link streamStoredZip} instead.
53
+ */
54
+ export async function packZipDirectory(
55
+ srcDir: string,
56
+ zipPath: string,
57
+ ): Promise<void> {
58
+ const root = resolve(srcDir)
59
+ const files = listFilesSorted(root)
60
+ const zip = new yazl.ZipFile()
61
+ for (const abs of files) {
62
+ // yazl wants metadata names with forward slashes; the local-path
63
+ // basename order is deterministic thanks to listFilesSorted.
64
+ const rel = abs
65
+ .slice(root.length + 1)
66
+ .split(sep)
67
+ .join("/")
68
+ zip.addFile(abs, rel, { compress: true })
69
+ }
70
+ zip.end()
71
+ await pipeline(zip.outputStream, createWriteStream(resolve(zipPath)))
72
+ }
73
+
74
+ /** Every regular file under `root`, sorted by relative path (recursive). */
75
+ function listFilesSorted(root: string): string[] {
76
+ const out: string[] = []
77
+ const walk = (dir: string) => {
78
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
79
+ const abs = join(dir, entry.name)
80
+ if (entry.isDirectory()) {
81
+ walk(abs)
82
+ continue
83
+ }
84
+ if (!entry.isFile()) continue
85
+ out.push(abs)
86
+ }
87
+ }
88
+ walk(root)
89
+ return out.sort()
90
+ }
@@ -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,
@@ -100,6 +100,7 @@ describe("createStoragePaths", () => {
100
100
  paths.local.trash(),
101
101
  paths.local.logs(),
102
102
  paths.local.sessionKey(),
103
+ paths.local.seedRemovals(),
103
104
  paths.local.uploadStagingRoot(),
104
105
  paths.local.stagingPoolRoot(),
105
106
  ]
@@ -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>`. */
@@ -235,6 +251,14 @@ export type LocalPaths = {
235
251
  * `local/` (never synced) so each host has its own seal key.
236
252
  */
237
253
  sessionKey(): string
254
+ /**
255
+ * Path to the seed-removal marker file: `<root>/local/seed-removals.json`.
256
+ * Holds the plugin ids (UUIDs) whose bundled seed was deliberately
257
+ * uninstalled by this host, so boot-time seeding skips them until the
258
+ * user restores them. Host-only state — never synced, survives app
259
+ * updates, and stays out of the wipe-on-clear `cache/` tree.
260
+ */
261
+ seedRemovals(): string
238
262
  /**
239
263
  * Root of the host-only temporary directory tree:
240
264
  * `<localRoot>/.tmp`. Holds the global staging pool
@@ -348,8 +372,10 @@ export function createStoragePaths(
348
372
  join(vRoot, "resources", assertSafeSegment(id), RESOURCE_DATA_DIR_NAME),
349
373
  resources: () => join(vRoot, "resources"),
350
374
  characters: () => join(vRoot, "characters"),
375
+ tags: () => join(vRoot, "tags"),
351
376
  documents: () => join(vRoot, "documents"),
352
377
  character: (id) => join(vRoot, "characters", assertSafeSegment(id)),
378
+ tag: (id) => join(vRoot, "tags", assertSafeSegment(id)),
353
379
  dbBackups: () => join(vRoot, "db-backups"),
354
380
  dbBackup: (name) => join(vRoot, "db-backups", assertSafeSegment(name)),
355
381
  snapshots: () => join(vRoot, "snapshots"),
@@ -393,11 +419,13 @@ export function createStoragePaths(
393
419
  join(cacheRoot, "resources", assertSafeSegment(id), "files-cache.json"),
394
420
  resource: (id) => join(cacheRoot, "resources", assertSafeSegment(id)),
395
421
  character: (id) => join(cacheRoot, "characters", assertSafeSegment(id)),
422
+ tag: (id) => join(cacheRoot, "tags", assertSafeSegment(id)),
396
423
  trash: () => join(localRoot, "trash"),
397
424
  trashItem: (id) => join(localRoot, "trash", assertSafeSegment(id)),
398
425
  tmp: () => join(cacheRoot, "tmp"),
399
426
  tmpFile: (name) => join(cacheRoot, "tmp", assertSafeSegment(name)),
400
427
  sessionKey: () => join(localRoot, ".session-key"),
428
+ seedRemovals: () => join(localRoot, "seed-removals.json"),
401
429
  uploadStagingRoot: () => uploadStagingRootPath,
402
430
  stagingPoolRoot: () => join(uploadStagingRootPath, "staging"),
403
431
  stagingPoolFile: (fileId, ext) =>
@@ -456,13 +484,20 @@ export function createStoragePaths(
456
484
  }
457
485
 
458
486
  /**
459
- * Map a {@link LocalPaths.thumb} subjectKind onto its on-disk subdirectory.
460
- * Variants now live flat inside the per-id local directory (no enclosing
461
- * `thumbs/` parent), so `resource` -> `resources` and `character` ->
462
- * `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).
463
492
  */
464
- function localCoverSubjectDir(subjectKind: "resource" | "character"): string {
465
- 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"
466
501
  }
467
502
 
468
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
 
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export {
37
37
  createNestedCdCache,
38
38
  listZipEntries,
39
39
  materializeFile,
40
+ packZipDirectory,
40
41
  } from "./archive/index.ts"
41
42
  export type {
42
43
  CapabilityGuard,
@@ -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
+ }