@openparachute/vault 0.7.4-rc.1 → 0.7.4
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/core/src/attachment/tickets.ts +9 -0
- package/core/src/mcp-manifest.ts +5 -1
- package/core/src/mcp.ts +8 -0
- package/package.json +1 -1
- package/src/attachment-tickets.test.ts +46 -0
- package/src/attachment-tickets.ts +6 -0
- package/src/routes.ts +15 -1
- package/src/vault.test.ts +167 -0
|
@@ -37,6 +37,15 @@ export interface AttachmentTicket {
|
|
|
37
37
|
noteId?: string;
|
|
38
38
|
filename?: string;
|
|
39
39
|
transcribe?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Upload only, voice W2 parity with the REST path's `segment_index`
|
|
42
|
+
* (`src/routes.ts` POST `/notes/:id/attachments`) — an integer >= 0 lets
|
|
43
|
+
* one recording split across several ticket-minted attachments on ONE
|
|
44
|
+
* note, each resolving into its own `(part N)` marker. Only meaningful
|
|
45
|
+
* alongside `transcribe: true`; validated at mint (`generateMcpTools`'s
|
|
46
|
+
* `request-attachment-upload`), consumed at spend (`handleTicketSpend`).
|
|
47
|
+
*/
|
|
48
|
+
segmentIndex?: number;
|
|
40
49
|
/** Download only. */
|
|
41
50
|
attachmentId?: string;
|
|
42
51
|
}
|
package/core/src/mcp-manifest.ts
CHANGED
|
@@ -677,7 +677,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
677
677
|
name: "request-attachment-upload",
|
|
678
678
|
requiredVerb: "write",
|
|
679
679
|
description:
|
|
680
|
-
"Mint a short-lived, single-use upload URL for a note attachment. Bytes never pass through this tool — you get back a URL (+ a ready-to-run `curl_example`) your runtime's shell spends directly; no MCP session credential is needed to spend it. Provide the target `note` (id or path), the `filename`, and its exact `size_bytes` — declared here and enforced at spend (a mismatch, or exceeding the 100 MiB REST upload cap, fails the mint or the upload). `mime_type` is inferred from the filename's extension when omitted. Pass `transcribe: true` for an audio file to enqueue it exactly like the REST attach flow does. The ticket's `expires_at` scales with declared size (10 minutes base + 10s per MiB, capped at 30 minutes) and can be spent exactly once — a failed curl means re-minting, not retrying the same URL.",
|
|
680
|
+
"Mint a short-lived, single-use upload URL for a note attachment. Bytes never pass through this tool — you get back a URL (+ a ready-to-run `curl_example`) your runtime's shell spends directly; no MCP session credential is needed to spend it. Provide the target `note` (id or path), the `filename`, and its exact `size_bytes` — declared here and enforced at spend (a mismatch, or exceeding the 100 MiB REST upload cap, fails the mint or the upload). `mime_type` is inferred from the filename's extension when omitted. Pass `transcribe: true` for an audio file to enqueue it exactly like the REST attach flow does; `segment_index` additionally splits one recording across several attachments on the same note (voice W2 — see that field's description). The ticket's `expires_at` scales with declared size (10 minutes base + 10s per MiB, capped at 30 minutes) and can be spent exactly once — a failed curl means re-minting, not retrying the same URL.",
|
|
681
681
|
inputSchema: {
|
|
682
682
|
type: "object",
|
|
683
683
|
properties: {
|
|
@@ -689,6 +689,10 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
689
689
|
},
|
|
690
690
|
mime_type: { type: "string", description: "MIME type to store on the attachment row. Inferred from `filename`'s extension when omitted (`application/octet-stream` for an uncurated extension)." },
|
|
691
691
|
transcribe: { type: "boolean", description: "Opt into transcription for an audio attachment — mirrors the REST `POST /notes/:id/attachments` `transcribe` flag." },
|
|
692
|
+
segment_index: {
|
|
693
|
+
type: "number",
|
|
694
|
+
description: "Only meaningful alongside `transcribe: true`. An integer >= 0 that marks this attachment as one part of a multi-part recording linked to the same note — each part resolves into its own `_Transcript pending (part N)._` marker (N = segment_index + 1) instead of overwriting a shared bare marker. A malformed value (non-integer, negative, non-number) is silently ignored, falling back to the un-segmented bare marker.",
|
|
695
|
+
},
|
|
692
696
|
},
|
|
693
697
|
required: ["note", "filename", "size_bytes"],
|
|
694
698
|
},
|
package/core/src/mcp.ts
CHANGED
|
@@ -2558,6 +2558,13 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
2558
2558
|
? params.mime_type
|
|
2559
2559
|
: mimeForAttachmentExtension(ext);
|
|
2560
2560
|
|
|
2561
|
+
// Per-segment slots (voice W2), ticket-mint parity with the REST
|
|
2562
|
+
// path's own validation (`src/routes.ts` POST /notes/:id/attachments):
|
|
2563
|
+
// an integer >= 0, else silently dropped — a malformed value falls
|
|
2564
|
+
// back to the un-segmented bare markers rather than erroring the mint.
|
|
2565
|
+
const segIdx = params.segment_index;
|
|
2566
|
+
const validSegment = typeof segIdx === "number" && Number.isInteger(segIdx) && segIdx >= 0;
|
|
2567
|
+
|
|
2561
2568
|
const now = Date.now();
|
|
2562
2569
|
const expiresAt = now + computeTicketTtlMs(sizeBytes);
|
|
2563
2570
|
const id = generateTicketId();
|
|
@@ -2572,6 +2579,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
2572
2579
|
mimeType,
|
|
2573
2580
|
sizeBytes,
|
|
2574
2581
|
transcribe: params.transcribe === true,
|
|
2582
|
+
...(validSegment ? { segmentIndex: segIdx } : {}),
|
|
2575
2583
|
};
|
|
2576
2584
|
await ticketSeam.provider.put(ticket);
|
|
2577
2585
|
|
package/package.json
CHANGED
|
@@ -270,6 +270,52 @@ describe("attachment tickets — upload lifecycle", () => {
|
|
|
270
270
|
expect((updatedNote!.metadata as any)?.transcribe_stub).toBe(true);
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
+
test("segment_index (voice W2): a valid integer >= 0 rides ticket mint through to the attachment row", async () => {
|
|
274
|
+
const vaultName = freshVault("tickets-segment");
|
|
275
|
+
const store = getVaultStore(vaultName);
|
|
276
|
+
const note = await store.createNote("# Voice memo\n\n_Transcript pending (part 2)._", { path: "memo-seg" });
|
|
277
|
+
|
|
278
|
+
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
279
|
+
note: note.id,
|
|
280
|
+
filename: "part-2.wav",
|
|
281
|
+
size_bytes: 4,
|
|
282
|
+
transcribe: true,
|
|
283
|
+
segment_index: 1,
|
|
284
|
+
});
|
|
285
|
+
const res = await routeReq(
|
|
286
|
+
new Request(mint.url, { method: "PUT", headers: { "content-type": "audio/wav" }, body: new Uint8Array([1, 2, 3, 4]) }),
|
|
287
|
+
);
|
|
288
|
+
expect(res.status).toBe(201);
|
|
289
|
+
const attachment = (await res.json()) as any;
|
|
290
|
+
expect(attachment.metadata.segment_index).toBe(1);
|
|
291
|
+
expect(attachment.metadata.transcribe_status).toBe("pending");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test.each([
|
|
295
|
+
["negative", -1],
|
|
296
|
+
["non-integer", 1.5],
|
|
297
|
+
["string", "1"],
|
|
298
|
+
])("segment_index (voice W2): an invalid value (%s) is dropped at mint, not stored — same fallback as the REST path", async (_label, bad) => {
|
|
299
|
+
const vaultName = freshVault(`tickets-segment-bad-${_label}`);
|
|
300
|
+
const store = getVaultStore(vaultName);
|
|
301
|
+
const note = await store.createNote("# Voice memo\n\n_Transcript pending._", { path: "memo-seg-bad" });
|
|
302
|
+
|
|
303
|
+
const mint = await callTool(vaultName, "request-attachment-upload", {
|
|
304
|
+
note: note.id,
|
|
305
|
+
filename: "bad.wav",
|
|
306
|
+
size_bytes: 4,
|
|
307
|
+
transcribe: true,
|
|
308
|
+
segment_index: bad,
|
|
309
|
+
});
|
|
310
|
+
const res = await routeReq(
|
|
311
|
+
new Request(mint.url, { method: "PUT", headers: { "content-type": "audio/wav" }, body: new Uint8Array([1, 2, 3, 4]) }),
|
|
312
|
+
);
|
|
313
|
+
expect(res.status).toBe(201);
|
|
314
|
+
const attachment = (await res.json()) as any;
|
|
315
|
+
expect(attachment.metadata.segment_index).toBeUndefined();
|
|
316
|
+
expect(attachment.metadata.transcribe_status).toBe("pending");
|
|
317
|
+
});
|
|
318
|
+
|
|
273
319
|
test("blocked extension is refused at MINT — no ticket is ever created", async () => {
|
|
274
320
|
const vaultName = freshVault("tickets-blocked-ext");
|
|
275
321
|
const store = getVaultStore(vaultName);
|
|
@@ -274,6 +274,12 @@ async function handleUploadSpend(
|
|
|
274
274
|
attMeta.transcribe_status = "pending";
|
|
275
275
|
attMeta.transcribe_requested_at = new Date().toISOString();
|
|
276
276
|
attMeta.transcribe_origin = explicitOptIn ? "legacy" : "auto";
|
|
277
|
+
// Per-segment slots (voice W2) — already validated at mint (the ticket
|
|
278
|
+
// only carries `segmentIndex` when it passed the integer->=0 check), so
|
|
279
|
+
// just thread it through onto the attachment row's metadata.
|
|
280
|
+
if (ticket.segmentIndex !== undefined) {
|
|
281
|
+
attMeta.segment_index = ticket.segmentIndex;
|
|
282
|
+
}
|
|
277
283
|
}
|
|
278
284
|
|
|
279
285
|
const attachment = await store.addAttachment(ticket.noteId, relativePath, ticket.mimeType, attMeta);
|
package/src/routes.ts
CHANGED
|
@@ -2334,7 +2334,12 @@ async function handleNotesInner(
|
|
|
2334
2334
|
}
|
|
2335
2335
|
const parsedBody = await parseJsonBody(req);
|
|
2336
2336
|
if (!parsedBody.ok) return parsedBody.response;
|
|
2337
|
-
const body = parsedBody.body as {
|
|
2337
|
+
const body = parsedBody.body as {
|
|
2338
|
+
path: string;
|
|
2339
|
+
mimeType: string;
|
|
2340
|
+
transcribe?: boolean;
|
|
2341
|
+
segment_index?: unknown;
|
|
2342
|
+
};
|
|
2338
2343
|
if (!body.path || !body.mimeType) {
|
|
2339
2344
|
return json(
|
|
2340
2345
|
{ error: "path and mimeType are required", error_type: "missing_required_field", hint: "pass both `path` and `mimeType`" },
|
|
@@ -2365,11 +2370,20 @@ async function handleNotesInner(
|
|
|
2365
2370
|
? readVaultConfig(vault)?.auto_transcribe?.enabled
|
|
2366
2371
|
: undefined;
|
|
2367
2372
|
const autoOptIn = !explicitOptIn && shouldAutoTranscribe(body.mimeType, { perVaultEnabled });
|
|
2373
|
+
// Per-segment slots (voice W2, cloud twin: workers/vault/src/rest/notes.ts
|
|
2374
|
+
// ~791-800): an optional `segment_index` (integer >= 0) lets one recording
|
|
2375
|
+
// split across several attachments on ONE note, each resolving into its
|
|
2376
|
+
// own `(part N)` marker (N = segment_index + 1, see transcription-worker.ts
|
|
2377
|
+
// markersFor). A malformed value is silently ignored — the attachment
|
|
2378
|
+
// still links, it just falls back to the bare, un-segmented markers.
|
|
2379
|
+
const segIdx = body.segment_index;
|
|
2380
|
+
const validSegment = typeof segIdx === "number" && Number.isInteger(segIdx) && segIdx >= 0;
|
|
2368
2381
|
const attMeta = (explicitOptIn || autoOptIn)
|
|
2369
2382
|
? {
|
|
2370
2383
|
transcribe_status: "pending" as const,
|
|
2371
2384
|
transcribe_requested_at: new Date().toISOString(),
|
|
2372
2385
|
transcribe_origin: (explicitOptIn ? "legacy" : "auto") as "legacy" | "auto",
|
|
2386
|
+
...(validSegment ? { segment_index: segIdx } : {}),
|
|
2373
2387
|
}
|
|
2374
2388
|
: undefined;
|
|
2375
2389
|
|
package/src/vault.test.ts
CHANGED
|
@@ -2913,6 +2913,173 @@ describe("HTTP /notes", async () => {
|
|
|
2913
2913
|
});
|
|
2914
2914
|
});
|
|
2915
2915
|
|
|
2916
|
+
describe("POST /notes/:id/attachments with segment_index (voice W2)", async () => {
|
|
2917
|
+
test("valid segment_index (integer >= 0) lands on the attachment's metadata", async () => {
|
|
2918
|
+
await store.createNote("# 🎙️ Voice memo\n\n_Transcript pending (part 1)._", { id: "seg1" });
|
|
2919
|
+
const res = await handleNotes(
|
|
2920
|
+
mkReq("POST", "/notes/seg1/attachments", {
|
|
2921
|
+
path: "memos/part-1.webm",
|
|
2922
|
+
mimeType: "audio/webm",
|
|
2923
|
+
transcribe: true,
|
|
2924
|
+
segment_index: 0,
|
|
2925
|
+
}),
|
|
2926
|
+
store,
|
|
2927
|
+
"/seg1/attachments",
|
|
2928
|
+
);
|
|
2929
|
+
expect(res.status).toBe(201);
|
|
2930
|
+
const att = await res.json() as any;
|
|
2931
|
+
expect(att.metadata?.segment_index).toBe(0);
|
|
2932
|
+
expect(att.metadata?.transcribe_status).toBe("pending");
|
|
2933
|
+
});
|
|
2934
|
+
|
|
2935
|
+
test.each([
|
|
2936
|
+
["negative", -1],
|
|
2937
|
+
["non-integer", 1.5],
|
|
2938
|
+
["string", "1"],
|
|
2939
|
+
])("invalid segment_index (%s) is dropped, not stored, same as cloud's fallback", async (_label, bad) => {
|
|
2940
|
+
await store.createNote("# 🎙️ Voice memo\n\n_Transcript pending._", { id: `seg-bad-${_label}` });
|
|
2941
|
+
const res = await handleNotes(
|
|
2942
|
+
mkReq("POST", `/notes/seg-bad-${_label}/attachments`, {
|
|
2943
|
+
path: "memos/bad.webm",
|
|
2944
|
+
mimeType: "audio/webm",
|
|
2945
|
+
transcribe: true,
|
|
2946
|
+
segment_index: bad,
|
|
2947
|
+
}),
|
|
2948
|
+
store,
|
|
2949
|
+
`/seg-bad-${_label}/attachments`,
|
|
2950
|
+
);
|
|
2951
|
+
// Malformed segment_index is NOT a request error — it silently falls
|
|
2952
|
+
// back to the un-segmented path (mirrors cloud's notes.ts validSegment
|
|
2953
|
+
// check), so linking still succeeds.
|
|
2954
|
+
expect(res.status).toBe(201);
|
|
2955
|
+
const att = await res.json() as any;
|
|
2956
|
+
expect(att.metadata?.segment_index).toBeUndefined();
|
|
2957
|
+
expect(att.metadata?.transcribe_status).toBe("pending");
|
|
2958
|
+
});
|
|
2959
|
+
|
|
2960
|
+
test("absent segment_index leaves the un-segmented path byte-unchanged", async () => {
|
|
2961
|
+
await store.createNote("# 🎙️ Voice memo\n\n_Transcript pending._", { id: "seg-absent" });
|
|
2962
|
+
const res = await handleNotes(
|
|
2963
|
+
mkReq("POST", "/notes/seg-absent/attachments", {
|
|
2964
|
+
path: "memos/bare.webm",
|
|
2965
|
+
mimeType: "audio/webm",
|
|
2966
|
+
transcribe: true,
|
|
2967
|
+
}),
|
|
2968
|
+
store,
|
|
2969
|
+
"/seg-absent/attachments",
|
|
2970
|
+
);
|
|
2971
|
+
expect(res.status).toBe(201);
|
|
2972
|
+
const att = await res.json() as any;
|
|
2973
|
+
expect(att.metadata?.segment_index).toBeUndefined();
|
|
2974
|
+
expect(att.metadata?.transcribe_status).toBe("pending");
|
|
2975
|
+
});
|
|
2976
|
+
|
|
2977
|
+
// ---- The join test, not just the door's half ------------------------
|
|
2978
|
+
//
|
|
2979
|
+
// The three tests above post `segment_index` at TOP LEVEL — the shape
|
|
2980
|
+
// both doors have now agreed on (cloud always read it there; self-host
|
|
2981
|
+
// didn't read it at all until this PR). That's a deliberate contract
|
|
2982
|
+
// choice, not a guess at what any client sends: the app was found
|
|
2983
|
+
// nesting it under `metadata` instead, which is the OTHER half of this
|
|
2984
|
+
// bug and is being fixed separately in sibling PR parachute-app#126
|
|
2985
|
+
// ("segment_index rides top-level on the wire"). Top-level is the one
|
|
2986
|
+
// true shape going forward; nested is a bug in the emitter, not a shape
|
|
2987
|
+
// either door should learn to accept.
|
|
2988
|
+
//
|
|
2989
|
+
// What this test proves: given a top-level `segment_index`, self-host
|
|
2990
|
+
// stores it AND the transcription worker resolves the correct per-part
|
|
2991
|
+
// marker from it — the full loop this door owns.
|
|
2992
|
+
// What it does NOT prove: that any real client actually sends this
|
|
2993
|
+
// shape today. app#126 pins the app's emission; this pins the door's
|
|
2994
|
+
// reception. Proving the two actually join — the app's real request
|
|
2995
|
+
// body landing on a running self-host vault and coming out right —
|
|
2996
|
+
// needs a cross-repo conformance test that doesn't exist yet (filed as
|
|
2997
|
+
// vault#629; that gap is exactly how this bug shipped in the first
|
|
2998
|
+
// place, since cloud's own conformance test was green against a shape
|
|
2999
|
+
// the app never sent).
|
|
3000
|
+
test("end-to-end: top-level segment_index on TWO real attachments resolves each part's marker independently", async () => {
|
|
3001
|
+
const assetsRoot = join(tmpDir, "assets");
|
|
3002
|
+
mkdirSync(join(assetsRoot, "memos"), { recursive: true });
|
|
3003
|
+
writeFileSync(join(assetsRoot, "memos/e2e-0.webm"), Buffer.from([1, 2, 3]));
|
|
3004
|
+
writeFileSync(join(assetsRoot, "memos/e2e-1.webm"), Buffer.from([4, 5, 6]));
|
|
3005
|
+
process.env.ASSETS_DIR = assetsRoot;
|
|
3006
|
+
|
|
3007
|
+
await store.createNote(
|
|
3008
|
+
"# 🎙️ Voice memo\n\n_Transcript pending (part 1)._\n\n_Transcript pending (part 2)._\n",
|
|
3009
|
+
{ id: "seg-e2e", metadata: { transcribe_stub: true } },
|
|
3010
|
+
);
|
|
3011
|
+
|
|
3012
|
+
// Link both parts through the REAL REST endpoint, exactly as the
|
|
3013
|
+
// (now-fixed) app will call it: top-level segment_index, not nested.
|
|
3014
|
+
const res0 = await handleNotes(
|
|
3015
|
+
mkReq("POST", "/notes/seg-e2e/attachments", {
|
|
3016
|
+
path: "memos/e2e-0.webm",
|
|
3017
|
+
mimeType: "audio/webm",
|
|
3018
|
+
transcribe: true,
|
|
3019
|
+
segment_index: 0,
|
|
3020
|
+
}),
|
|
3021
|
+
store,
|
|
3022
|
+
"/seg-e2e/attachments",
|
|
3023
|
+
);
|
|
3024
|
+
const res1 = await handleNotes(
|
|
3025
|
+
mkReq("POST", "/notes/seg-e2e/attachments", {
|
|
3026
|
+
path: "memos/e2e-1.webm",
|
|
3027
|
+
mimeType: "audio/webm",
|
|
3028
|
+
transcribe: true,
|
|
3029
|
+
segment_index: 1,
|
|
3030
|
+
}),
|
|
3031
|
+
store,
|
|
3032
|
+
"/seg-e2e/attachments",
|
|
3033
|
+
);
|
|
3034
|
+
expect(res0.status).toBe(201);
|
|
3035
|
+
expect(res1.status).toBe(201);
|
|
3036
|
+
const att0 = await res0.json() as any;
|
|
3037
|
+
const att1 = await res1.json() as any;
|
|
3038
|
+
|
|
3039
|
+
const worker = startTranscriptionWorker({
|
|
3040
|
+
vaultList: () => ["default"],
|
|
3041
|
+
getStore: () => store as unknown as Store,
|
|
3042
|
+
scribeUrl: "http://scribe.test",
|
|
3043
|
+
resolveAssetsDir: () => process.env.ASSETS_DIR!,
|
|
3044
|
+
pollIntervalMs: 10_000_000,
|
|
3045
|
+
maxAttempts: 3,
|
|
3046
|
+
fetchImpl: (async () => new Response(
|
|
3047
|
+
JSON.stringify({ text: "part text" }),
|
|
3048
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
3049
|
+
)) as typeof fetch,
|
|
3050
|
+
logger: { error: () => {}, info: () => {} },
|
|
3051
|
+
});
|
|
3052
|
+
try {
|
|
3053
|
+
// Complete part 2 first, then part 1 — out of order, same as the
|
|
3054
|
+
// original bug report.
|
|
3055
|
+
await worker.kick("default", att1);
|
|
3056
|
+
const midway = await store.getNote("seg-e2e");
|
|
3057
|
+
expect(midway!.content).toBe(
|
|
3058
|
+
"# 🎙️ Voice memo\n\n_Transcript pending (part 1)._\n\npart text\n",
|
|
3059
|
+
);
|
|
3060
|
+
// Shared stub SURVIVES — part 1 still needs the gate open. Before
|
|
3061
|
+
// this fix, an unstored segment_index made every part look
|
|
3062
|
+
// un-segmented, so completing ONE part cleared the stub and locked
|
|
3063
|
+
// the other out (the exact production bug).
|
|
3064
|
+
expect((midway!.metadata as any)?.transcribe_stub).toBe(true);
|
|
3065
|
+
|
|
3066
|
+
await worker.kick("default", att0);
|
|
3067
|
+
const final = await store.getNote("seg-e2e");
|
|
3068
|
+
expect(final!.content).toBe(
|
|
3069
|
+
"# 🎙️ Voice memo\n\npart text\n\npart text\n",
|
|
3070
|
+
);
|
|
3071
|
+
// Segmented notes never auto-clear the shared stub, even once every
|
|
3072
|
+
// part is done (pre-existing worker contract, pinned separately in
|
|
3073
|
+
// transcription-worker.test.ts) — out of scope here, asserted only
|
|
3074
|
+
// so this test doesn't silently rely on behavior this PR didn't add.
|
|
3075
|
+
expect((final!.metadata as any)?.transcribe_stub).toBe(true);
|
|
3076
|
+
} finally {
|
|
3077
|
+
await worker.stop();
|
|
3078
|
+
delete process.env.ASSETS_DIR;
|
|
3079
|
+
}
|
|
3080
|
+
});
|
|
3081
|
+
});
|
|
3082
|
+
|
|
2916
3083
|
describe("DELETE /notes/:id/attachments/:attId", async () => {
|
|
2917
3084
|
test("happy path: 204, DB row gone, storage file unlinked", async () => {
|
|
2918
3085
|
const assetsRoot = join(tmpDir, "assets");
|