@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.
- package/dist/chunks/worker-entry.mjs +6 -1
- package/dist/hoard/index.d.ts +44 -42
- package/dist/hoard/index.js +73 -47
- package/dist/hoard/index.js.map +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.js +126 -23
- package/dist/index.js.map +1 -1
- package/dist/pack-6IscrwYN.d.ts +35 -0
- package/package.json +2 -2
- package/src/archive/archive-ops.test.ts +49 -1
- package/src/archive/extract.ts +15 -0
- package/src/archive/index.ts +1 -1
- package/src/archive/pack.ts +50 -0
- package/src/hoard/index.ts +4 -1
- package/src/hoard/paths.test.ts +1 -0
- package/src/hoard/paths.ts +46 -11
- package/src/hoard/versioned-folder-ops.ts +29 -6
- package/src/hooks.test.ts +70 -0
- package/src/hooks.ts +26 -0
- package/src/index.ts +1 -0
- package/src/install-api.ts +68 -0
- package/src/sandbox/host.ts +30 -1
- package/src/sandbox/worker-entry.mjs +6 -1
|
@@ -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.
|
|
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.
|
|
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()
|
package/src/archive/extract.ts
CHANGED
|
@@ -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
|
}
|
package/src/archive/index.ts
CHANGED
|
@@ -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,
|
package/src/archive/pack.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/hoard/index.ts
CHANGED
|
@@ -75,7 +75,10 @@ export {
|
|
|
75
75
|
versionedPath,
|
|
76
76
|
writeActiveVersion,
|
|
77
77
|
} from "./version.ts"
|
|
78
|
-
export type {
|
|
78
|
+
export type {
|
|
79
|
+
VersionedFolderOps,
|
|
80
|
+
VersionedFolderSubjectKind,
|
|
81
|
+
} from "./versioned-folder-ops.ts"
|
|
79
82
|
export {
|
|
80
83
|
archiveStaleFiles,
|
|
81
84
|
buildVersionedFolderOps,
|
package/src/hoard/paths.test.ts
CHANGED
package/src/hoard/paths.ts
CHANGED
|
@@ -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>/`, `
|
|
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.
|
|
460
|
-
* Variants now live flat inside the per-id local directory
|
|
461
|
-
* `thumbs/` parent), so `resource` -> `resources
|
|
462
|
-
* `characters` (
|
|
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(
|
|
465
|
-
|
|
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
|
|
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:
|
|
55
|
+
kind: VersionedFolderSubjectKind,
|
|
42
56
|
): VersionedFolderOps {
|
|
43
57
|
const folderOf =
|
|
44
58
|
(id: string) =>
|
|
45
|
-
(current: StoragePaths["latest"]): string =>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
@@ -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
|
+
}
|