@openparachute/vault 0.6.3-rc.1 → 0.6.3-rc.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/core/src/onboarding.ts +116 -1
- package/core/src/vault-projection.ts +33 -1
- package/package.json +1 -1
- package/src/mcp-tools.ts +29 -0
- package/src/onboarding-seed.test.ts +72 -0
- package/src/vault.test.ts +19 -0
package/core/src/onboarding.ts
CHANGED
|
@@ -91,6 +91,48 @@ schemas.** Start minimal — invent tags as real notes need them, declare a
|
|
|
91
91
|
schema only when a query demands it. Over-designing an empty vault is the
|
|
92
92
|
common mistake.
|
|
93
93
|
|
|
94
|
+
Declaring a schema is one \`update-tag\` call — the \`fields\` object maps each
|
|
95
|
+
field name to \`{ type, enum?, indexed? }\` (\`type\` is \`"string"\`, \`"boolean"\`,
|
|
96
|
+
or \`"integer"\`):
|
|
97
|
+
|
|
98
|
+
\`\`\`
|
|
99
|
+
update-tag {
|
|
100
|
+
tag: "meeting",
|
|
101
|
+
description: "A meeting with notes",
|
|
102
|
+
fields: {
|
|
103
|
+
held_on: { type: "string", indexed: true }, // queryable with operators
|
|
104
|
+
status: { type: "string", enum: ["scheduled", "done"] }, // first enum value is the default
|
|
105
|
+
rating: { type: "integer" }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
\`\`\`
|
|
109
|
+
|
|
110
|
+
\`fields\` is **merged** (new keys added, existing replaced); \`parent_names\` and
|
|
111
|
+
\`relationships\` are replaced wholesale when passed. Only \`indexed: true\` fields
|
|
112
|
+
support operator queries (\`metadata: { held_on: { gte: "..." } }\`) and
|
|
113
|
+
\`order_by\`; all tags declaring the same field must agree on its \`type\` and
|
|
114
|
+
\`indexed\` flag.
|
|
115
|
+
|
|
116
|
+
## Write gotchas
|
|
117
|
+
|
|
118
|
+
A few behaviors worth knowing before you write at scale:
|
|
119
|
+
|
|
120
|
+
- **\`update-note\` requires optimistic concurrency by default.** Pass
|
|
121
|
+
\`if_updated_at\` with the \`updated_at\` you last read; a mismatch returns a
|
|
122
|
+
conflict error (re-read, reconcile, retry). For bulk/scripted writes where
|
|
123
|
+
concurrency is known-safe, pass \`force: true\` to waive the *requirement to
|
|
124
|
+
supply* it. \`append\`/\`prepend\`-only updates are exempt (no-conflict-by-design).
|
|
125
|
+
- **A schema field's default is filled in on write, so it shows up even when you
|
|
126
|
+
didn't set it.** When a note gets a tag whose schema declares a field, the
|
|
127
|
+
missing field is back-filled: an \`enum\` field → its **first listed value**, an
|
|
128
|
+
\`integer\` → \`0\`, a \`boolean\` → \`false\`, a plain string → \`""\`. So a
|
|
129
|
+
\`rating: { type: "integer" }\` reads as \`0\` on notes nobody rated — that \`0\`
|
|
130
|
+
is "unset," not "rated zero." Order an \`enum\`'s values so the first is a sane
|
|
131
|
+
default, and don't read a back-filled \`0\`/\`""\`/\`false\` as a real value.
|
|
132
|
+
- **Validation is advisory, never blocking.** A type/enum mismatch comes back as
|
|
133
|
+
a \`validation_status\` warning on the write response — the write still lands.
|
|
134
|
+
Read those warnings and self-correct on the next turn.
|
|
135
|
+
|
|
94
136
|
(Full design guide, with copy-paste examples: https://parachute.computer/scripting/)
|
|
95
137
|
|
|
96
138
|
## Importing existing notes
|
|
@@ -146,6 +188,17 @@ single-purpose tool. This note is a living starter for building one *with the
|
|
|
146
188
|
operator*. Update it as you settle on a stack, conventions, or a deployed
|
|
147
189
|
surface for this vault.
|
|
148
190
|
|
|
191
|
+
## ⚠️ Build a surface in your editor, not from this session
|
|
192
|
+
|
|
193
|
+
A surface runs **in a browser**: it needs a real OAuth round-trip (a redirect to
|
|
194
|
+
the hub's consent screen and back), a dev server to serve the app, and a CORS
|
|
195
|
+
origin the hub trusts. **None of that exists in this MCP/chat session** — there's
|
|
196
|
+
no browser, no redirect, no dev server. So **don't try to "run" a surface from
|
|
197
|
+
the vault session.** Build it in **Claude Code (or your editor)** against a local
|
|
198
|
+
dev server (\`vite\`/\`bun dev\`), sign in through the browser there, and iterate.
|
|
199
|
+
From *this* session you design the vault structure the surface will consume and
|
|
200
|
+
write the code — you can't exercise the OAuth/render loop here.
|
|
201
|
+
|
|
149
202
|
## Don't hand-roll the plumbing
|
|
150
203
|
|
|
151
204
|
Two published packages do the heavy lifting — import them instead of writing
|
|
@@ -158,10 +211,72 @@ OAuth, the vault API client, or note rendering by hand:
|
|
|
158
211
|
content (Markdown, wikilinks, embeds) the way the rest of the ecosystem does,
|
|
159
212
|
so your surface looks native without re-implementing the renderer.
|
|
160
213
|
|
|
214
|
+
## Minimal end-to-end (config → sign-in → query → render)
|
|
215
|
+
|
|
216
|
+
A React sketch wiring all four steps. \`createVaultSurface\` is the only required
|
|
217
|
+
config (its \`clientName\` is the sole required option; \`hubUrl\` defaults to the
|
|
218
|
+
page origin, \`vaultName\` to \`"default"\`, \`scope\` to \`"vault:read vault:write"\`).
|
|
219
|
+
\`getClient()\` returns a \`VaultClient\` (or \`null\` until signed in) whose
|
|
220
|
+
\`queryNotes()\` takes the same query grammar you use over MCP. See
|
|
221
|
+
[[Getting Started]] / \`vault-info\` for this vault's NAME and hub origin.
|
|
222
|
+
|
|
223
|
+
\`\`\`tsx
|
|
224
|
+
import { useEffect, useState } from "react";
|
|
225
|
+
import { createVaultSurface, type Note } from "@openparachute/surface-client";
|
|
226
|
+
import { NoteRenderer } from "@openparachute/surface-render";
|
|
227
|
+
|
|
228
|
+
// One surface per (hub, vault) config. clientName shows on the consent screen.
|
|
229
|
+
const surface = createVaultSurface({
|
|
230
|
+
clientName: "My Vault Surface",
|
|
231
|
+
hubUrl: "https://your-hub.example", // omit to default to window.location.origin
|
|
232
|
+
vaultName: "default", // this vault's name (see vault-info)
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
export function App() {
|
|
236
|
+
const [notes, setNotes] = useState<Note[] | null>(null);
|
|
237
|
+
|
|
238
|
+
useEffect(() => {
|
|
239
|
+
(async () => {
|
|
240
|
+
// OAuth: finish a redirect callback if we're on it, else send the browser
|
|
241
|
+
// off to sign in. handleCallback() needs BOTH code + state, so guard on
|
|
242
|
+
// both. (Real apps route /oauth/callback to its own component.)
|
|
243
|
+
const q = new URLSearchParams(location.search);
|
|
244
|
+
if (q.get("code") && q.get("state")) await surface.handleCallback();
|
|
245
|
+
// getClient() builds a FRESH VaultClient on each call — fine here (one-shot
|
|
246
|
+
// effect); in a real component keep it in state/ref, don't call it per render.
|
|
247
|
+
const client = surface.getClient(); // VaultClient | null (null = not signed in)
|
|
248
|
+
if (!client) return void surface.login();
|
|
249
|
+
setNotes(await client.queryNotes({ tag: "note", limit: 20 }));
|
|
250
|
+
})();
|
|
251
|
+
}, []);
|
|
252
|
+
|
|
253
|
+
if (!notes) return <p>Connecting…</p>;
|
|
254
|
+
return (
|
|
255
|
+
<>
|
|
256
|
+
{notes.map((n) => (
|
|
257
|
+
// resolve maps a [[wikilink]] target → { href, exists } (or null = inert).
|
|
258
|
+
// You own this href's trust boundary — keep it a fragment (or validate the
|
|
259
|
+
// target). Don't build a raw passthrough href: a vault note could carry a
|
|
260
|
+
// javascript: target.
|
|
261
|
+
<NoteRenderer
|
|
262
|
+
key={n.id}
|
|
263
|
+
note={n}
|
|
264
|
+
resolve={(target) => ({ href: \`#/n/\${encodeURIComponent(target)}\`, exists: true })}
|
|
265
|
+
/>
|
|
266
|
+
))}
|
|
267
|
+
</>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
\`\`\`
|
|
271
|
+
|
|
272
|
+
That's the whole spine. \`<NoteRenderer>\` also takes \`linkComponent\` (your
|
|
273
|
+
router's \`<Link>\`) and \`fetchBlob\` (\`(url) => Promise<Blob>\`, for auth'd
|
|
274
|
+
image/audio embeds) when you need them — both optional.
|
|
275
|
+
|
|
161
276
|
## Build order
|
|
162
277
|
|
|
163
278
|
1. **Auth + data first.** Stand up \`createVaultSurface\` pointed at this vault;
|
|
164
|
-
confirm you can sign in and \`
|
|
279
|
+
confirm you can sign in and \`queryNotes\` round-trips before any UI polish.
|
|
165
280
|
2. **Render next.** Drop in \`<NoteRenderer>\` to display note content; wire
|
|
166
281
|
wikilink/embed resolution through the package, not by hand.
|
|
167
282
|
3. **UX last.** Layout, navigation, and the surface's actual purpose — now that
|
|
@@ -273,8 +273,19 @@ export function projectionToMarkdown(args: {
|
|
|
273
273
|
vaultName: string;
|
|
274
274
|
description?: string | null;
|
|
275
275
|
projection: VaultProjection;
|
|
276
|
+
/**
|
|
277
|
+
* This vault's own public coordinates, surfaced so a surface-builder never has
|
|
278
|
+
* to guess the vault NAME or reconstruct the REST/MCP URLs from the connector
|
|
279
|
+
* config. When `hubOrigin` is a known absolute origin (configured
|
|
280
|
+
* `PARACHUTE_HUB_ORIGIN` or an active expose-state FQDN), the absolute base
|
|
281
|
+
* URLs are rendered; when it's only the loopback fallback (no public origin
|
|
282
|
+
* known to the vault), relative path templates are rendered with a note that
|
|
283
|
+
* the origin is whatever the client connected through. The NAME is always
|
|
284
|
+
* known and always surfaced. See `chooseHubOrigin` (src/mcp-install.ts).
|
|
285
|
+
*/
|
|
286
|
+
coordinates?: { hubOrigin: string; hubOriginKnown: boolean };
|
|
276
287
|
}): string {
|
|
277
|
-
const { vaultName, description, projection } = args;
|
|
288
|
+
const { vaultName, description, projection, coordinates } = args;
|
|
278
289
|
const stats = projection.stats;
|
|
279
290
|
|
|
280
291
|
const lines: string[] = [];
|
|
@@ -284,6 +295,27 @@ export function projectionToMarkdown(args: {
|
|
|
284
295
|
lines.push(description.trim());
|
|
285
296
|
}
|
|
286
297
|
|
|
298
|
+
// GAP 3 / vault coordinates: state the vault's own NAME + REST/MCP URLs so a
|
|
299
|
+
// surface-builder (or any scripting client) doesn't have to learn them from
|
|
300
|
+
// the MCP connector config. Absolute when the vault knows its public origin;
|
|
301
|
+
// relative templates otherwise.
|
|
302
|
+
if (coordinates) {
|
|
303
|
+
lines.push("");
|
|
304
|
+
lines.push("## This vault's coordinates");
|
|
305
|
+
lines.push("");
|
|
306
|
+
lines.push(`- Name: \`${vaultName}\``);
|
|
307
|
+
if (coordinates.hubOriginKnown) {
|
|
308
|
+
const base = coordinates.hubOrigin.replace(/\/$/, "");
|
|
309
|
+
lines.push(`- REST API base: \`${base}/vault/${vaultName}/api/...\``);
|
|
310
|
+
lines.push(`- MCP endpoint: \`${base}/vault/${vaultName}/mcp\``);
|
|
311
|
+
} else {
|
|
312
|
+
lines.push(
|
|
313
|
+
`- REST API: \`<hub-origin>/vault/${vaultName}/api/...\` — \`<hub-origin>\` is the origin you connected through (the vault has no public origin configured to render here).`,
|
|
314
|
+
);
|
|
315
|
+
lines.push(`- MCP endpoint: \`<hub-origin>/vault/${vaultName}/mcp\``);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
287
319
|
// A2: surface the seeded onboarding guide so a connected AI can discover it
|
|
288
320
|
// when setting up or orienting. A pointer only — the AI fetches the body on
|
|
289
321
|
// demand. NOT a mandate to re-read every session; it's a start-here guide.
|
package/package.json
CHANGED
package/src/mcp-tools.ts
CHANGED
|
@@ -100,9 +100,23 @@ export async function getServerInstruction(
|
|
|
100
100
|
vaultName,
|
|
101
101
|
description: config?.description ?? null,
|
|
102
102
|
projection,
|
|
103
|
+
coordinates: resolveVaultCoordinates(),
|
|
103
104
|
});
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Resolve this vault's own public coordinates for the projection. The vault
|
|
109
|
+
* always knows its NAME; its hub origin comes from `resolveHubOrigin()`
|
|
110
|
+
* (PARACHUTE_HUB_ORIGIN → expose-state FQDN → loopback). A loopback source
|
|
111
|
+
* means no public origin is configured — flagged via `hubOriginKnown: false`
|
|
112
|
+
* so the projection renders relative path templates rather than a loopback URL
|
|
113
|
+
* a remote surface-builder can't use.
|
|
114
|
+
*/
|
|
115
|
+
function resolveVaultCoordinates(): { hubOrigin: string; hubOriginKnown: boolean } {
|
|
116
|
+
const { url, source } = resolveHubOrigin();
|
|
117
|
+
return { hubOrigin: url, hubOriginKnown: source !== "loopback" };
|
|
118
|
+
}
|
|
119
|
+
|
|
106
120
|
/**
|
|
107
121
|
* Generate MCP tools scoped to a single vault.
|
|
108
122
|
*
|
|
@@ -482,9 +496,24 @@ function overrideVaultInfo(
|
|
|
482
496
|
const includeStats = Boolean(params.include_stats);
|
|
483
497
|
const projection = buildVaultProjection(store.db, { includeStats });
|
|
484
498
|
|
|
499
|
+
// GAP 3 / vault coordinates: surface the vault's own NAME + REST/MCP URL
|
|
500
|
+
// templates so a surface-builder doesn't have to learn them from the MCP
|
|
501
|
+
// connector config. `base_url` is absolute when the vault knows its public
|
|
502
|
+
// origin (PARACHUTE_HUB_ORIGIN / expose-state); null on a loopback-only
|
|
503
|
+
// box, where `rest_api` / `mcp` carry `<hub-origin>` placeholder templates
|
|
504
|
+
// resolved against whatever origin the client connected through.
|
|
505
|
+
const coords = resolveVaultCoordinates();
|
|
506
|
+
const coordBase = coords.hubOriginKnown ? coords.hubOrigin.replace(/\/$/, "") : "<hub-origin>";
|
|
507
|
+
|
|
485
508
|
const result: Record<string, unknown> = {
|
|
486
509
|
name: config.name,
|
|
487
510
|
description: config.description ?? null,
|
|
511
|
+
coordinates: {
|
|
512
|
+
name: config.name,
|
|
513
|
+
base_url: coords.hubOriginKnown ? `${coordBase}/vault/${config.name}` : null,
|
|
514
|
+
rest_api: `${coordBase}/vault/${config.name}/api`,
|
|
515
|
+
mcp: `${coordBase}/vault/${config.name}/mcp`,
|
|
516
|
+
},
|
|
488
517
|
tags: projection.tags,
|
|
489
518
|
indexed_fields: projection.indexed_fields,
|
|
490
519
|
query_hints: projection.query_hints,
|
|
@@ -67,6 +67,35 @@ describe("seedOnboardingNotes (A1/A3)", () => {
|
|
|
67
67
|
expect(ss!.content).toContain("@openparachute/surface-render");
|
|
68
68
|
expect(ss!.content).toContain("createVaultSurface");
|
|
69
69
|
expect(ss!.content).toContain("NoteRenderer");
|
|
70
|
+
|
|
71
|
+
// GAP 2: warn that a surface can't be run/developed from this MCP session.
|
|
72
|
+
expect(ss!.content).toContain("not from this");
|
|
73
|
+
expect(ss!.content.toLowerCase()).toContain("browser");
|
|
74
|
+
|
|
75
|
+
// GAP 1: a real, copy-paste end-to-end snippet — every load-bearing symbol
|
|
76
|
+
// present, verified against the surface-client / surface-render source.
|
|
77
|
+
expect(ss!.content).toContain("clientName"); // the one required createVaultSurface option
|
|
78
|
+
expect(ss!.content).toContain("getClient");
|
|
79
|
+
expect(ss!.content).toContain("queryNotes");
|
|
80
|
+
expect(ss!.content).toContain("handleCallback");
|
|
81
|
+
expect(ss!.content).toContain("resolve="); // NoteRenderer wikilink resolver prop
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("GAP 4: Getting Started shows a concrete update-tag fields example + write gotchas", async () => {
|
|
85
|
+
await seedOnboardingNotes(store);
|
|
86
|
+
const gs = await store.getNoteByPath(GETTING_STARTED_PATH);
|
|
87
|
+
|
|
88
|
+
// (a) a literal update-tag fields object: field name → { type, enum?, indexed? }.
|
|
89
|
+
expect(gs!.content).toContain("update-tag {");
|
|
90
|
+
expect(gs!.content).toContain('{ type: "string", indexed: true }');
|
|
91
|
+
expect(gs!.content).toContain('enum: ["scheduled", "done"]');
|
|
92
|
+
|
|
93
|
+
// (b) the write-gotchas subsection — each gotcha verified against mcp.ts.
|
|
94
|
+
expect(gs!.content).toContain("## Write gotchas");
|
|
95
|
+
expect(gs!.content).toContain("if_updated_at"); // optimistic concurrency
|
|
96
|
+
expect(gs!.content).toContain("force: true");
|
|
97
|
+
// schema-default back-fill: enum→first value, integer→0.
|
|
98
|
+
expect(gs!.content).toContain("first listed value");
|
|
70
99
|
});
|
|
71
100
|
|
|
72
101
|
test("A3: Getting Started links to Surface Starter via a resolved wikilink", async () => {
|
|
@@ -148,3 +177,46 @@ describe("vault-info / projection pointer (A2)", () => {
|
|
|
148
177
|
expect(md).not.toContain("## Start here");
|
|
149
178
|
});
|
|
150
179
|
});
|
|
180
|
+
|
|
181
|
+
describe("vault coordinates in the projection (GAP 3)", () => {
|
|
182
|
+
test("renders absolute REST + MCP URLs when the hub origin is known", () => {
|
|
183
|
+
const projection = buildVaultProjection(db, { includeStats: true });
|
|
184
|
+
const md = projectionToMarkdown({
|
|
185
|
+
vaultName: "research",
|
|
186
|
+
description: null,
|
|
187
|
+
projection,
|
|
188
|
+
coordinates: { hubOrigin: "https://hub.example.com", hubOriginKnown: true },
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(md).toContain("## This vault's coordinates");
|
|
192
|
+
expect(md).toContain("Name: `research`");
|
|
193
|
+
expect(md).toContain("https://hub.example.com/vault/research/api/...");
|
|
194
|
+
expect(md).toContain("https://hub.example.com/vault/research/mcp");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("renders relative templates + connected-origin note when origin is unknown", () => {
|
|
198
|
+
const projection = buildVaultProjection(db, { includeStats: true });
|
|
199
|
+
const md = projectionToMarkdown({
|
|
200
|
+
vaultName: "research",
|
|
201
|
+
description: null,
|
|
202
|
+
projection,
|
|
203
|
+
coordinates: { hubOrigin: "http://127.0.0.1:1940", hubOriginKnown: false },
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(md).toContain("## This vault's coordinates");
|
|
207
|
+
expect(md).toContain("Name: `research`");
|
|
208
|
+
// The loopback URL is NOT leaked; relative templates carry a placeholder.
|
|
209
|
+
expect(md).not.toContain("127.0.0.1");
|
|
210
|
+
expect(md).toContain("<hub-origin>/vault/research/api/...");
|
|
211
|
+
expect(md).toContain("<hub-origin>/vault/research/mcp");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("no coordinates block when coordinates are omitted; name still in the opening line", () => {
|
|
215
|
+
const projection = buildVaultProjection(db, { includeStats: true });
|
|
216
|
+
const md = projectionToMarkdown({ vaultName: "research", description: null, projection });
|
|
217
|
+
// Without coordinates the dedicated block is absent; the vault NAME is still
|
|
218
|
+
// stated in the pre-existing opening line (the always-known coordinate).
|
|
219
|
+
expect(md).toContain('Parachute Vault "research"');
|
|
220
|
+
expect(md).not.toContain("## This vault's coordinates");
|
|
221
|
+
});
|
|
222
|
+
});
|
package/src/vault.test.ts
CHANGED
|
@@ -578,6 +578,13 @@ describe("scoped MCP wrapper", async () => {
|
|
|
578
578
|
const { writeVaultConfig } = await import("./config.ts");
|
|
579
579
|
const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
|
|
580
580
|
|
|
581
|
+
// The GAP-3 coordinates block reads PARACHUTE_HUB_ORIGIN; another test FILE
|
|
582
|
+
// in a shared `bun test ./src` run may have left it set (cross-file env
|
|
583
|
+
// pollution — e.g. hub-jwt.test.ts). Pin loopback so the coordinates
|
|
584
|
+
// assertion below is deterministic regardless of file order; restore after.
|
|
585
|
+
const prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
|
|
586
|
+
delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
587
|
+
|
|
581
588
|
const vaultName = `proj-${Date.now()}`;
|
|
582
589
|
writeVaultConfig({
|
|
583
590
|
name: vaultName,
|
|
@@ -641,12 +648,24 @@ describe("scoped MCP wrapper", async () => {
|
|
|
641
648
|
expect(Array.isArray(result.query_hints)).toBe(true);
|
|
642
649
|
expect((result.query_hints as string[]).length).toBeGreaterThan(0);
|
|
643
650
|
|
|
651
|
+
// GAP 3 — coordinates block: the vault states its own NAME + REST/MCP URL
|
|
652
|
+
// templates so a surface-builder doesn't reconstruct them from the
|
|
653
|
+
// connector config. No public hub origin in this test fixture → loopback →
|
|
654
|
+
// `<hub-origin>` placeholder templates, `base_url` null.
|
|
655
|
+
const coords = result.coordinates as Record<string, unknown>;
|
|
656
|
+
expect(coords.name).toBe(vaultName);
|
|
657
|
+
expect(coords.rest_api).toBe(`<hub-origin>/vault/${vaultName}/api`);
|
|
658
|
+
expect(coords.mcp).toBe(`<hub-origin>/vault/${vaultName}/mcp`);
|
|
659
|
+
expect(coords.base_url).toBeNull();
|
|
660
|
+
|
|
644
661
|
// stats omitted unless requested
|
|
645
662
|
expect(result.stats).toBeUndefined();
|
|
646
663
|
|
|
647
664
|
const withStats = await vaultInfo.execute({ include_stats: true }) as any;
|
|
648
665
|
expect(withStats.stats).toBeTruthy();
|
|
649
666
|
|
|
667
|
+
if (prevHubOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
668
|
+
else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
|
|
650
669
|
closeAllStores();
|
|
651
670
|
});
|
|
652
671
|
|