@vellumai/cli 0.8.8-dev.202606081143.f600053 → 0.8.8-dev.202606081339.938c6ec
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/login.ts +128 -9
- package/src/lib/__tests__/docker.test.ts +93 -0
- package/src/lib/docker.ts +87 -29
package/package.json
CHANGED
package/src/commands/login.ts
CHANGED
|
@@ -32,6 +32,131 @@ import { syncCloudAssistants } from "../lib/sync-cloud-assistants";
|
|
|
32
32
|
|
|
33
33
|
const LOGIN_TIMEOUT_MS = 120_000; // 2 minutes
|
|
34
34
|
|
|
35
|
+
function escapeHtml(s: string): string {
|
|
36
|
+
return s
|
|
37
|
+
.replace(/&/g, "&")
|
|
38
|
+
.replace(/</g, "<")
|
|
39
|
+
.replace(/>/g, ">")
|
|
40
|
+
.replace(/"/g, """)
|
|
41
|
+
.replace(/'/g, "'");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function renderLoginPage(title: string, subtitle: string, success: boolean): string {
|
|
45
|
+
const checkmarkSvg = `<svg class="icon" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
46
|
+
<circle cx="28" cy="28" r="28" fill="var(--positive-bg)"/>
|
|
47
|
+
<path class="check" d="M17 28.5L24.5 36L39 21" stroke="var(--positive-fg)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
48
|
+
</svg>`;
|
|
49
|
+
|
|
50
|
+
const errorSvg = `<svg class="icon" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
51
|
+
<circle cx="28" cy="28" r="28" fill="var(--negative-bg)"/>
|
|
52
|
+
<path class="cross cross-1" d="M20 20L36 36" stroke="var(--negative-fg)" stroke-width="3.5" stroke-linecap="round" fill="none"/>
|
|
53
|
+
<path class="cross cross-2" d="M36 20L20 36" stroke="var(--negative-fg)" stroke-width="3.5" stroke-linecap="round" fill="none"/>
|
|
54
|
+
</svg>`;
|
|
55
|
+
|
|
56
|
+
return `<!DOCTYPE html>
|
|
57
|
+
<html lang="en">
|
|
58
|
+
<head>
|
|
59
|
+
<meta charset="utf-8">
|
|
60
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
61
|
+
<title>${escapeHtml(title)}</title>
|
|
62
|
+
<style>
|
|
63
|
+
:root {
|
|
64
|
+
--surface: #F5F3EB;
|
|
65
|
+
--surface-card: #FFFFFF;
|
|
66
|
+
--card-border: #E8E6DA;
|
|
67
|
+
--text-primary: #2A2A28;
|
|
68
|
+
--text-secondary: #4A4A46;
|
|
69
|
+
--positive-bg: #D4DFD0;
|
|
70
|
+
--positive-fg: #516748;
|
|
71
|
+
--negative-bg: #F7DAC9;
|
|
72
|
+
--negative-fg: #DA491A;
|
|
73
|
+
--shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.06);
|
|
74
|
+
--font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
|
|
75
|
+
}
|
|
76
|
+
@media (prefers-color-scheme: dark) {
|
|
77
|
+
:root {
|
|
78
|
+
--surface: #1A1A18;
|
|
79
|
+
--surface-card: #2A2A28;
|
|
80
|
+
--card-border: #3A3A37;
|
|
81
|
+
--text-primary: #F5F3EB;
|
|
82
|
+
--text-secondary: #BDB9A9;
|
|
83
|
+
--positive-bg: #1A2316;
|
|
84
|
+
--positive-fg: #7A8B6F;
|
|
85
|
+
--negative-bg: #4E281D;
|
|
86
|
+
--negative-fg: #E86B40;
|
|
87
|
+
--shadow: 0 1px 3px rgba(0,0,0,0.2), 0 4px 12px rgba(0,0,0,0.3);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
91
|
+
body {
|
|
92
|
+
font-family: var(--font);
|
|
93
|
+
background: var(--surface);
|
|
94
|
+
color: var(--text-primary);
|
|
95
|
+
display: flex;
|
|
96
|
+
align-items: center;
|
|
97
|
+
justify-content: center;
|
|
98
|
+
min-height: 100vh;
|
|
99
|
+
-webkit-font-smoothing: antialiased;
|
|
100
|
+
}
|
|
101
|
+
.card {
|
|
102
|
+
text-align: center;
|
|
103
|
+
padding: 48px 40px 40px;
|
|
104
|
+
background: var(--surface-card);
|
|
105
|
+
border: 1px solid var(--card-border);
|
|
106
|
+
border-radius: 16px;
|
|
107
|
+
box-shadow: var(--shadow);
|
|
108
|
+
max-width: 380px;
|
|
109
|
+
width: 100%;
|
|
110
|
+
opacity: 0;
|
|
111
|
+
transform: translateY(8px) scale(0.98);
|
|
112
|
+
animation: cardIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.1s forwards;
|
|
113
|
+
}
|
|
114
|
+
@keyframes cardIn {
|
|
115
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
116
|
+
}
|
|
117
|
+
.icon {
|
|
118
|
+
width: 56px;
|
|
119
|
+
height: 56px;
|
|
120
|
+
margin-bottom: 20px;
|
|
121
|
+
}
|
|
122
|
+
.check {
|
|
123
|
+
stroke-dasharray: 32;
|
|
124
|
+
stroke-dashoffset: 32;
|
|
125
|
+
animation: draw 0.4s ease-out 0.45s forwards;
|
|
126
|
+
}
|
|
127
|
+
.cross {
|
|
128
|
+
stroke-dasharray: 22;
|
|
129
|
+
stroke-dashoffset: 22;
|
|
130
|
+
}
|
|
131
|
+
.cross-1 { animation: draw 0.3s ease-out 0.45s forwards; }
|
|
132
|
+
.cross-2 { animation: draw 0.3s ease-out 0.55s forwards; }
|
|
133
|
+
@keyframes draw {
|
|
134
|
+
to { stroke-dashoffset: 0; }
|
|
135
|
+
}
|
|
136
|
+
h1 {
|
|
137
|
+
font-size: 18px;
|
|
138
|
+
font-weight: 600;
|
|
139
|
+
letter-spacing: -0.2px;
|
|
140
|
+
color: var(--text-primary);
|
|
141
|
+
margin-bottom: 6px;
|
|
142
|
+
}
|
|
143
|
+
p {
|
|
144
|
+
font-size: 13px;
|
|
145
|
+
line-height: 1.5;
|
|
146
|
+
color: var(--text-secondary);
|
|
147
|
+
}
|
|
148
|
+
</style>
|
|
149
|
+
</head>
|
|
150
|
+
<body>
|
|
151
|
+
<div class="card">
|
|
152
|
+
${success ? checkmarkSvg : errorSvg}
|
|
153
|
+
<h1>${escapeHtml(title)}</h1>
|
|
154
|
+
<p>${escapeHtml(subtitle)}</p>
|
|
155
|
+
</div>
|
|
156
|
+
</body>
|
|
157
|
+
</html>`;
|
|
158
|
+
}
|
|
159
|
+
|
|
35
160
|
/**
|
|
36
161
|
* Open a URL in the user's default browser.
|
|
37
162
|
*/
|
|
@@ -72,26 +197,20 @@ function browserLogin(webUrl: string): Promise<string> {
|
|
|
72
197
|
|
|
73
198
|
if (receivedState !== state) {
|
|
74
199
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
75
|
-
res.end(
|
|
76
|
-
"<html><body><h2>Login failed</h2><p>State mismatch. Please try again.</p></body></html>",
|
|
77
|
-
);
|
|
200
|
+
res.end(renderLoginPage("Login Failed", "State mismatch. Please try again.", false));
|
|
78
201
|
cleanup("State mismatch — possible CSRF attack.");
|
|
79
202
|
return;
|
|
80
203
|
}
|
|
81
204
|
|
|
82
205
|
if (!sessionToken) {
|
|
83
206
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
84
|
-
res.end(
|
|
85
|
-
"<html><body><h2>Login failed</h2><p>No session token received. Please try again.</p></body></html>",
|
|
86
|
-
);
|
|
207
|
+
res.end(renderLoginPage("Login Failed", "No session token received. Please try again.", false));
|
|
87
208
|
cleanup("No session token received from platform.");
|
|
88
209
|
return;
|
|
89
210
|
}
|
|
90
211
|
|
|
91
212
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
92
|
-
res.end(
|
|
93
|
-
"<html><body><h2>Login successful!</h2><p>You can close this window and return to your terminal.</p></body></html>",
|
|
94
|
-
);
|
|
213
|
+
res.end(renderLoginPage("Login Successful", "You can close this window and return to your terminal.", true));
|
|
95
214
|
cleanup(null, sessionToken);
|
|
96
215
|
});
|
|
97
216
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, test, expect } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
2
5
|
import {
|
|
3
6
|
ASSISTANT_INTERNAL_PORT,
|
|
4
7
|
AVATAR_DEVICE_ENV_VAR,
|
|
8
|
+
collectWatchTargets,
|
|
5
9
|
dockerResourceNames,
|
|
6
10
|
resolveAvatarDevicePath,
|
|
7
11
|
resolveDockerHatchMode,
|
|
@@ -277,3 +281,92 @@ describe("resolveDockerHatchMode", () => {
|
|
|
277
281
|
).toEqual({ build: false, watcher: false, fellBackToPull: true });
|
|
278
282
|
});
|
|
279
283
|
});
|
|
284
|
+
|
|
285
|
+
describe("collectWatchTargets", () => {
|
|
286
|
+
let repoRoot: string;
|
|
287
|
+
|
|
288
|
+
beforeEach(() => {
|
|
289
|
+
repoRoot = mkdtempSync(join(tmpdir(), "vellum-watch-"));
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
afterEach(() => {
|
|
293
|
+
rmSync(repoRoot, { recursive: true, force: true });
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
function scaffold(relDir: string, { src = true, pkg = true } = {}): void {
|
|
297
|
+
if (src) mkdirSync(join(repoRoot, relDir, "src"), { recursive: true });
|
|
298
|
+
if (pkg) {
|
|
299
|
+
mkdirSync(join(repoRoot, relDir), { recursive: true });
|
|
300
|
+
writeFileSync(join(repoRoot, relDir, "package.json"), "{}");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
test("scopes watch targets to each service's src/ tree and package.json", () => {
|
|
305
|
+
// GIVEN the three services plus a couple of shared packages, each with a
|
|
306
|
+
// src/ directory and a package.json manifest
|
|
307
|
+
scaffold("assistant");
|
|
308
|
+
scaffold("credential-executor");
|
|
309
|
+
scaffold("gateway");
|
|
310
|
+
scaffold("packages/service-contracts");
|
|
311
|
+
scaffold("packages/local-mode");
|
|
312
|
+
|
|
313
|
+
// WHEN we collect the watch targets
|
|
314
|
+
const { dirs, files } = collectWatchTargets(repoRoot);
|
|
315
|
+
|
|
316
|
+
// THEN only the src/ directories are watched recursively
|
|
317
|
+
expect(dirs.sort()).toEqual(
|
|
318
|
+
[
|
|
319
|
+
join(repoRoot, "assistant", "src"),
|
|
320
|
+
join(repoRoot, "credential-executor", "src"),
|
|
321
|
+
join(repoRoot, "gateway", "src"),
|
|
322
|
+
join(repoRoot, "packages", "local-mode", "src"),
|
|
323
|
+
join(repoRoot, "packages", "service-contracts", "src"),
|
|
324
|
+
].sort(),
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// AND only the package.json manifests are watched as files
|
|
328
|
+
expect(files.sort()).toEqual(
|
|
329
|
+
[
|
|
330
|
+
join(repoRoot, "assistant", "package.json"),
|
|
331
|
+
join(repoRoot, "credential-executor", "package.json"),
|
|
332
|
+
join(repoRoot, "gateway", "package.json"),
|
|
333
|
+
join(repoRoot, "packages", "local-mode", "package.json"),
|
|
334
|
+
join(repoRoot, "packages", "service-contracts", "package.json"),
|
|
335
|
+
].sort(),
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("never watches .claude/ command symlinks that crash the watcher", () => {
|
|
340
|
+
// GIVEN an assistant service whose .claude/commands holds a dangling
|
|
341
|
+
// symlink (as it does in a fresh checkout)
|
|
342
|
+
scaffold("assistant");
|
|
343
|
+
mkdirSync(join(repoRoot, "assistant", ".claude", "commands"), {
|
|
344
|
+
recursive: true,
|
|
345
|
+
});
|
|
346
|
+
symlinkSync(
|
|
347
|
+
join(repoRoot, "does-not-exist", "do.md"),
|
|
348
|
+
join(repoRoot, "assistant", ".claude", "commands", "do.md"),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
// WHEN we collect the watch targets
|
|
352
|
+
const { dirs, files } = collectWatchTargets(repoRoot);
|
|
353
|
+
|
|
354
|
+
// THEN no watched path reaches into the .claude/ tree
|
|
355
|
+
const all = [...dirs, ...files];
|
|
356
|
+
expect(all.some((p) => p.includes(".claude"))).toBe(false);
|
|
357
|
+
expect(dirs).toContain(join(repoRoot, "assistant", "src"));
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("skips roots missing a src/ directory or package.json", () => {
|
|
361
|
+
// GIVEN a service with only a manifest and a package with only a src/ dir
|
|
362
|
+
scaffold("gateway", { src: false, pkg: true });
|
|
363
|
+
scaffold("packages/contracts-only", { src: true, pkg: false });
|
|
364
|
+
|
|
365
|
+
// WHEN we collect the watch targets
|
|
366
|
+
const { dirs, files } = collectWatchTargets(repoRoot);
|
|
367
|
+
|
|
368
|
+
// THEN absent paths are not emitted
|
|
369
|
+
expect(dirs).toEqual([join(repoRoot, "packages", "contracts-only", "src")]);
|
|
370
|
+
expect(files).toEqual([join(repoRoot, "gateway", "package.json")]);
|
|
371
|
+
});
|
|
372
|
+
});
|
package/src/lib/docker.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
existsSync,
|
|
5
5
|
mkdirSync,
|
|
6
6
|
readFileSync,
|
|
7
|
+
readdirSync,
|
|
7
8
|
watch as fsWatch,
|
|
8
9
|
} from "fs";
|
|
9
10
|
import { arch, platform } from "os";
|
|
@@ -788,6 +789,51 @@ export async function captureImageRefs(
|
|
|
788
789
|
return hasAll ? (refs as Record<ServiceName, string>) : null;
|
|
789
790
|
}
|
|
790
791
|
|
|
792
|
+
/**
|
|
793
|
+
* Build the set of paths the hot-reload watcher should observe, scoped to
|
|
794
|
+
* each service's `src/` tree and `package.json` manifest.
|
|
795
|
+
*
|
|
796
|
+
* We deliberately avoid recursively watching whole service directories.
|
|
797
|
+
* Those contain `.claude/` command symlinks — which dangle in a fresh
|
|
798
|
+
* checkout because they point at the separately-cloned `claude-skills`
|
|
799
|
+
* repo — as well as `node_modules`. `fs.watch(dir, { recursive: true })`
|
|
800
|
+
* traverses those entries and emits an unhandled `error` event on a broken
|
|
801
|
+
* symlink, which crashes the CLI process. Source code only ever lives under
|
|
802
|
+
* `src/` (plus the manifest), so watching those paths preserves hot-reload
|
|
803
|
+
* without walking into symlinked or generated trees.
|
|
804
|
+
*
|
|
805
|
+
* Returning a plain record keeps this trivially unit-testable — see
|
|
806
|
+
* `__tests__/docker.test.ts`.
|
|
807
|
+
*/
|
|
808
|
+
export function collectWatchTargets(repoRoot: string): {
|
|
809
|
+
dirs: string[];
|
|
810
|
+
files: string[];
|
|
811
|
+
} {
|
|
812
|
+
const packagesDir = join(repoRoot, "packages");
|
|
813
|
+
const packageRoots = existsSync(packagesDir)
|
|
814
|
+
? readdirSync(packagesDir, { withFileTypes: true })
|
|
815
|
+
.filter((entry) => entry.isDirectory())
|
|
816
|
+
.map((entry) => join(packagesDir, entry.name))
|
|
817
|
+
: [];
|
|
818
|
+
|
|
819
|
+
const serviceRoots = [
|
|
820
|
+
join(repoRoot, "assistant"),
|
|
821
|
+
join(repoRoot, "credential-executor"),
|
|
822
|
+
join(repoRoot, "gateway"),
|
|
823
|
+
...packageRoots,
|
|
824
|
+
];
|
|
825
|
+
|
|
826
|
+
const dirs: string[] = [];
|
|
827
|
+
const files: string[] = [];
|
|
828
|
+
for (const root of serviceRoots) {
|
|
829
|
+
const srcDir = join(root, "src");
|
|
830
|
+
if (existsSync(srcDir)) dirs.push(srcDir);
|
|
831
|
+
const manifest = join(root, "package.json");
|
|
832
|
+
if (existsSync(manifest)) files.push(manifest);
|
|
833
|
+
}
|
|
834
|
+
return { dirs, files };
|
|
835
|
+
}
|
|
836
|
+
|
|
791
837
|
/**
|
|
792
838
|
* Determine which services are affected by a changed file path relative
|
|
793
839
|
* to the repository root.
|
|
@@ -821,9 +867,10 @@ function affectedServices(
|
|
|
821
867
|
}
|
|
822
868
|
|
|
823
869
|
/**
|
|
824
|
-
* Watch for
|
|
825
|
-
* and packages
|
|
826
|
-
*
|
|
870
|
+
* Watch for source changes across the assistant, gateway, credential-executor,
|
|
871
|
+
* and packages services — scoped to each service's `src/` tree and
|
|
872
|
+
* `package.json` (see `collectWatchTargets`). When changes are detected,
|
|
873
|
+
* rebuild the affected images and restart their containers.
|
|
827
874
|
*/
|
|
828
875
|
function startFileWatcher(opts: {
|
|
829
876
|
signingKey?: string;
|
|
@@ -837,12 +884,7 @@ function startFileWatcher(opts: {
|
|
|
837
884
|
}): () => void {
|
|
838
885
|
const { gatewayPort, imageTags, instanceName, repoRoot, res } = opts;
|
|
839
886
|
|
|
840
|
-
const watchDirs =
|
|
841
|
-
join(repoRoot, "assistant"),
|
|
842
|
-
join(repoRoot, "credential-executor"),
|
|
843
|
-
join(repoRoot, "gateway"),
|
|
844
|
-
join(repoRoot, "packages"),
|
|
845
|
-
];
|
|
887
|
+
const { dirs: watchDirs, files: watchFiles } = collectWatchTargets(repoRoot);
|
|
846
888
|
|
|
847
889
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
848
890
|
let pendingServices = new Set<ServiceName>();
|
|
@@ -919,37 +961,53 @@ function startFileWatcher(opts: {
|
|
|
919
961
|
|
|
920
962
|
const watchers: ReturnType<typeof fsWatch>[] = [];
|
|
921
963
|
|
|
964
|
+
function onChange(fullPath: string): void {
|
|
965
|
+
const services = affectedServices(fullPath, repoRoot);
|
|
966
|
+
if (services.size === 0) return;
|
|
967
|
+
|
|
968
|
+
for (const s of services) {
|
|
969
|
+
pendingServices.add(s);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
973
|
+
debounceTimer = setTimeout(() => {
|
|
974
|
+
debounceTimer = null;
|
|
975
|
+
rebuildAndRestart();
|
|
976
|
+
}, 500);
|
|
977
|
+
}
|
|
978
|
+
|
|
922
979
|
for (const dir of watchDirs) {
|
|
923
|
-
if (!existsSync(dir)) continue;
|
|
924
980
|
const watcher = fsWatch(dir, { recursive: true }, (_event, filename) => {
|
|
925
981
|
if (!filename) return;
|
|
926
|
-
if (
|
|
927
|
-
filename.includes("node_modules") ||
|
|
928
|
-
filename.includes(".env") ||
|
|
929
|
-
filename.startsWith(".")
|
|
930
|
-
) {
|
|
982
|
+
if (filename.includes("node_modules") || filename.includes(".env")) {
|
|
931
983
|
return;
|
|
932
984
|
}
|
|
985
|
+
onChange(join(dir, filename));
|
|
986
|
+
});
|
|
987
|
+
// fs.watch surfaces transient errors (e.g. an unreadable entry) as an
|
|
988
|
+
// `error` event, which would otherwise crash the process. Log and keep
|
|
989
|
+
// the remaining watchers running.
|
|
990
|
+
watcher.on("error", (err) => {
|
|
991
|
+
console.error(
|
|
992
|
+
`⚠️ File watcher error for ${dir}: ${err instanceof Error ? err.message : err}`,
|
|
993
|
+
);
|
|
994
|
+
});
|
|
995
|
+
watchers.push(watcher);
|
|
996
|
+
}
|
|
933
997
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
if (debounceTimer) clearTimeout(debounceTimer);
|
|
943
|
-
debounceTimer = setTimeout(() => {
|
|
944
|
-
debounceTimer = null;
|
|
945
|
-
rebuildAndRestart();
|
|
946
|
-
}, 500);
|
|
998
|
+
for (const file of watchFiles) {
|
|
999
|
+
const watcher = fsWatch(file, () => onChange(file));
|
|
1000
|
+
watcher.on("error", (err) => {
|
|
1001
|
+
console.error(
|
|
1002
|
+
`⚠️ File watcher error for ${file}: ${err instanceof Error ? err.message : err}`,
|
|
1003
|
+
);
|
|
947
1004
|
});
|
|
948
1005
|
watchers.push(watcher);
|
|
949
1006
|
}
|
|
950
1007
|
|
|
951
1008
|
console.log("👀 Watching for file changes in:");
|
|
952
|
-
console.log("
|
|
1009
|
+
console.log(" <service>/src and <service>/package.json for");
|
|
1010
|
+
console.log(" assistant/, gateway/, credential-executor/, packages/*");
|
|
953
1011
|
console.log("");
|
|
954
1012
|
|
|
955
1013
|
return () => {
|