@wyattjoh/demur 0.1.0

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/src/analyze.ts ADDED
@@ -0,0 +1,621 @@
1
+ /**
2
+ * What a path points at, decided by code rather than by the model.
3
+ *
4
+ * Classification is a *fact* ("this resolves inside the OS temp directory"),
5
+ * not a policy ("therefore allow it"). The model still decides what the fact
6
+ * means; code just stops making it guess at string manipulation it cannot do.
7
+ */
8
+ export type PathClass =
9
+ | "temp"
10
+ | "system"
11
+ | "filesystem-root"
12
+ | "home-root"
13
+ | "home"
14
+ | "inside-working-directory"
15
+ | "relative"
16
+ | "remote"
17
+ | "unresolved-variable"
18
+ | "glob"
19
+ | "unknown";
20
+
21
+ /**
22
+ * A path found in a command, with what code could determine about it.
23
+ */
24
+ export type AnalyzedPath = {
25
+ /**
26
+ * The path exactly as it appeared, after quote removal.
27
+ */
28
+ value: string;
29
+ /**
30
+ * The path with `~`, known variables, and `..` resolved, when possible.
31
+ */
32
+ resolved: string | undefined;
33
+ /**
34
+ * What the resolved path points at.
35
+ */
36
+ class: PathClass;
37
+ /**
38
+ * Whether the path as written contained a variable expansion, whatever it
39
+ * resolved to here.
40
+ *
41
+ * `$TMPDIR/build` can resolve to a temp directory on one machine and anywhere
42
+ * else on another. Resolution at judgment time therefore proves nothing about
43
+ * resolution at execution time.
44
+ */
45
+ variableRooted: boolean;
46
+ };
47
+
48
+ /**
49
+ * One command in a compound command line.
50
+ */
51
+ export type Segment = {
52
+ /**
53
+ * The segment text as written.
54
+ */
55
+ raw: string;
56
+ /**
57
+ * The program actually being invoked, after stripping wrappers, environment
58
+ * assignments, and any leading directory.
59
+ *
60
+ * This is the field that stops `transform data` from reading as `rm`.
61
+ */
62
+ argv0: string | undefined;
63
+ /**
64
+ * Wrapper programs stripped to find `argv0`, such as `env`, `sudo`, `time`.
65
+ */
66
+ wrappers: string[];
67
+ /**
68
+ * Arguments after `argv0`, with quotes removed.
69
+ */
70
+ args: string[];
71
+ /**
72
+ * Paths found among the arguments.
73
+ */
74
+ paths: AnalyzedPath[];
75
+ };
76
+
77
+ /**
78
+ * A heredoc found in the command.
79
+ */
80
+ export type Heredoc = {
81
+ /**
82
+ * The delimiter word.
83
+ */
84
+ tag: string;
85
+ /**
86
+ * Whether the delimiter was quoted, which suppresses shell expansion in the
87
+ * body and is strong evidence the body is inert data.
88
+ */
89
+ quoted: boolean;
90
+ /**
91
+ * The program the body is fed to, when one could be determined.
92
+ */
93
+ consumer: string | undefined;
94
+ /**
95
+ * The body text.
96
+ */
97
+ body: string;
98
+ };
99
+
100
+ /**
101
+ * Everything code can determine about a command without running it.
102
+ */
103
+ export type CommandAnalysis = {
104
+ /**
105
+ * Commands in the line, split on shell operators and unwrapped from
106
+ * groupings and control flow.
107
+ */
108
+ segments: Segment[];
109
+ /**
110
+ * Commands found inside `$(...)` or backticks. These execute, and are easy to
111
+ * miss when reading the outer command as text.
112
+ */
113
+ substitutions: string[];
114
+ /**
115
+ * Heredocs, whose bodies are usually data rather than commands.
116
+ */
117
+ heredocs: Heredoc[];
118
+ /**
119
+ * Whether anything in the command failed to parse cleanly.
120
+ */
121
+ parsedCleanly: boolean;
122
+ /**
123
+ * Whether what this command will actually do cannot be determined from its
124
+ * text alone.
125
+ *
126
+ * True when a target path is variable-rooted or glob-expanded, when the
127
+ * command runs something from a substitution, or when it did not parse. These
128
+ * are the cases where a judgment reads the command's *apparent* meaning while
129
+ * its real meaning depends on the environment at execution time.
130
+ */
131
+ staticallyUnresolvable: boolean;
132
+ };
133
+
134
+ /**
135
+ * Programs that pass execution through to another program, hiding the real
136
+ * `argv0` behind themselves.
137
+ */
138
+ const WRAPPERS = new Set([
139
+ "env",
140
+ "sudo",
141
+ "doas",
142
+ "time",
143
+ "nohup",
144
+ "nice",
145
+ "ionice",
146
+ "command",
147
+ "builtin",
148
+ "exec",
149
+ "setsid",
150
+ "stdbuf",
151
+ "timeout",
152
+ "xargs",
153
+ "watch",
154
+ ]);
155
+
156
+ /**
157
+ * Shell reserved words that can precede a simple command inside control flow.
158
+ */
159
+ const KEYWORDS = new Set([
160
+ "if",
161
+ "then",
162
+ "else",
163
+ "elif",
164
+ "fi",
165
+ "do",
166
+ "done",
167
+ "while",
168
+ "until",
169
+ "for",
170
+ "case",
171
+ "esac",
172
+ "select",
173
+ "function",
174
+ "{",
175
+ "}",
176
+ "!",
177
+ ]);
178
+
179
+ /**
180
+ * Extract heredocs and replace their bodies with a placeholder.
181
+ *
182
+ * Bodies are pulled out before tokenizing so that a `rm -rf /` sitting inside a
183
+ * quoted heredoc body is never mistaken for a token of the command itself.
184
+ *
185
+ * @param command - The raw command line
186
+ * @returns The heredocs found and the command with bodies removed
187
+ */
188
+ function extractHeredocs(command: string): {
189
+ heredocs: Heredoc[];
190
+ stripped: string;
191
+ } {
192
+ const heredocs: Heredoc[] = [];
193
+ const lines = command.split("\n");
194
+ const kept: string[] = [];
195
+
196
+ for (let i = 0; i < lines.length; i += 1) {
197
+ const line = lines[i];
198
+ if (line === undefined) continue;
199
+
200
+ const match = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line);
201
+ if (match === null) {
202
+ kept.push(line);
203
+ continue;
204
+ }
205
+
206
+ const tag = match[2] ?? "";
207
+ const quoted = match[1] !== "";
208
+ const consumer = /^\s*([A-Za-z0-9_./-]+)/.exec(line)?.[1];
209
+
210
+ const body: string[] = [];
211
+ let j = i + 1;
212
+ for (; j < lines.length; j += 1) {
213
+ const candidate = lines[j];
214
+ if (candidate === undefined) break;
215
+ if (candidate.trim() === tag) break;
216
+ body.push(candidate);
217
+ }
218
+
219
+ heredocs.push({ tag, quoted, consumer, body: body.join("\n") });
220
+ kept.push(line.replace(match[0], "<<HEREDOC_BODY"));
221
+ i = j;
222
+ }
223
+
224
+ return { heredocs, stripped: kept.join("\n") };
225
+ }
226
+
227
+ /**
228
+ * Collect the commands inside `$(...)` and backtick substitutions.
229
+ *
230
+ * @param text - Command text to scan
231
+ * @returns The inner command strings
232
+ */
233
+ function extractSubstitutions(text: string): string[] {
234
+ const found: string[] = [];
235
+
236
+ for (const m of text.matchAll(/\$\(([^()]*)\)/g)) {
237
+ const inner = m[1]?.trim();
238
+ if (inner) found.push(inner);
239
+ }
240
+ for (const m of text.matchAll(/`([^`]*)`/g)) {
241
+ const inner = m[1]?.trim();
242
+ if (inner) found.push(inner);
243
+ }
244
+
245
+ return found;
246
+ }
247
+
248
+ /**
249
+ * Split a command line into segments on shell operators.
250
+ *
251
+ * Also unwraps groupings and drops control-flow keywords, so that
252
+ * `(chmod -R 755 /etc)` and `if true; then chmod -R 755 /etc; fi` both yield a
253
+ * segment whose `argv0` is `chmod` rather than `(chmod` or `then`.
254
+ *
255
+ * @param text - Command text with heredoc bodies already removed
256
+ * @returns Segment strings, trimmed and non-empty
257
+ */
258
+ function splitSegments(text: string): string[] {
259
+ const parts: string[] = [];
260
+ let current = "";
261
+ let quote: string | undefined;
262
+
263
+ for (let i = 0; i < text.length; i += 1) {
264
+ const ch = text[i];
265
+ if (ch === undefined) continue;
266
+
267
+ if (quote !== undefined) {
268
+ current += ch;
269
+ if (ch === quote) quote = undefined;
270
+ continue;
271
+ }
272
+
273
+ if (ch === "'" || ch === '"') {
274
+ quote = ch;
275
+ current += ch;
276
+ continue;
277
+ }
278
+
279
+ const next = text[i + 1];
280
+ if ((ch === "&" && next === "&") || (ch === "|" && next === "|")) {
281
+ parts.push(current);
282
+ current = "";
283
+ i += 1;
284
+ continue;
285
+ }
286
+ if (ch === ";" || ch === "|" || ch === "\n" || ch === "&") {
287
+ parts.push(current);
288
+ current = "";
289
+ continue;
290
+ }
291
+ current += ch;
292
+ }
293
+ parts.push(current);
294
+
295
+ return parts
296
+ .map((p) => p.trim().replace(/^[({]\s*/, "").replace(/\s*[)}]$/, "").trim())
297
+ .filter((p) => p !== "");
298
+ }
299
+
300
+ /**
301
+ * Split a segment into tokens, removing quotes.
302
+ *
303
+ * Quote removal is what collapses `rm -rf''` and `rm -r'f` back to `rm -rf`.
304
+ * A backslash is treated as a literal character rather than an escape when it
305
+ * is followed by a non-space, so Windows paths such as `C:\Users\u` survive
306
+ * tokenizing intact.
307
+ *
308
+ * An unterminated quote is reported rather than silently swallowed: `rm -r'f /`
309
+ * is not a valid command, and saying so is more useful to the model than
310
+ * inventing a plausible tokenization for it.
311
+ *
312
+ * @param segment - A single command segment
313
+ * @returns Tokens with quotes stripped, and whether a quote was left open
314
+ */
315
+ function tokenize(segment: string): { tokens: string[]; unterminated: boolean } {
316
+ const tokens: string[] = [];
317
+ let current = "";
318
+ let quote: string | undefined;
319
+ let started = false;
320
+
321
+ for (let i = 0; i < segment.length; i += 1) {
322
+ const ch = segment[i];
323
+ if (ch === undefined) continue;
324
+
325
+ if (quote !== undefined) {
326
+ if (ch === quote) quote = undefined;
327
+ else current += ch;
328
+ started = true;
329
+ continue;
330
+ }
331
+
332
+ if (ch === "'" || ch === '"') {
333
+ quote = ch;
334
+ started = true;
335
+ continue;
336
+ }
337
+
338
+ if (ch === "\\" && segment[i + 1] === " ") {
339
+ current += " ";
340
+ i += 1;
341
+ started = true;
342
+ continue;
343
+ }
344
+
345
+ if (/\s/.test(ch)) {
346
+ if (started) tokens.push(current);
347
+ current = "";
348
+ started = false;
349
+ continue;
350
+ }
351
+
352
+ current += ch;
353
+ started = true;
354
+ }
355
+
356
+ if (started) tokens.push(current);
357
+ return { tokens, unterminated: quote !== undefined };
358
+ }
359
+
360
+ /**
361
+ * Find the program a segment actually invokes.
362
+ *
363
+ * Drops leading `VAR=value` assignments and shell keywords, unwraps wrapper
364
+ * programs, and reduces a path-qualified program to its base name so that
365
+ * `/usr/bin/git` and `git` read identically.
366
+ *
367
+ * @param tokens - Tokens of one segment
368
+ * @returns The resolved program name, the wrappers stripped, and the remaining arguments
369
+ */
370
+ function resolveArgv0(tokens: string[]): {
371
+ argv0: string | undefined;
372
+ wrappers: string[];
373
+ args: string[];
374
+ } {
375
+ const wrappers: string[] = [];
376
+ let rest = [...tokens];
377
+
378
+ while (rest.length > 0) {
379
+ const head = rest[0];
380
+ if (head === undefined) break;
381
+
382
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(head)) {
383
+ rest = rest.slice(1);
384
+ continue;
385
+ }
386
+ if (KEYWORDS.has(head)) {
387
+ rest = rest.slice(1);
388
+ continue;
389
+ }
390
+
391
+ const base = head.split(/[/\\]/).pop() ?? head;
392
+ if (WRAPPERS.has(base)) {
393
+ wrappers.push(base);
394
+ rest = rest.slice(1);
395
+ // `env -C dir cmd` and `timeout 5s cmd`: skip the wrapper's own options
396
+ // and their values so the next word really is the wrapped program.
397
+ while (rest.length > 0) {
398
+ const opt = rest[0];
399
+ if (opt === undefined) break;
400
+ if (opt.startsWith("-")) {
401
+ rest = rest.slice(1);
402
+ const value = rest[0];
403
+ if (value !== undefined && !value.startsWith("-") && opt.length === 2) {
404
+ rest = rest.slice(1);
405
+ }
406
+ continue;
407
+ }
408
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(opt)) {
409
+ rest = rest.slice(1);
410
+ continue;
411
+ }
412
+ if (/^\d+[smh]?$/.test(opt) && wrappers.includes("timeout")) {
413
+ rest = rest.slice(1);
414
+ continue;
415
+ }
416
+ break;
417
+ }
418
+ continue;
419
+ }
420
+
421
+ return { argv0: base, wrappers, args: rest.slice(1) };
422
+ }
423
+
424
+ return { argv0: undefined, wrappers, args: [] };
425
+ }
426
+
427
+ /**
428
+ * Normalize `.` and `..` segments in a path without touching the filesystem.
429
+ *
430
+ * This matters for classification: `.../AppData/Local/Temp/../../Documents`
431
+ * must not be read as a temp path.
432
+ *
433
+ * @param path - A path that may contain relative segments
434
+ * @returns The path with `.` and `..` resolved textually
435
+ */
436
+ function normalizeDots(path: string): string {
437
+ const windows = /^[A-Za-z]:/.test(path) || path.includes("\\");
438
+ const sep = windows ? "\\" : "/";
439
+ const unified = path.replace(/\\/g, "/");
440
+ const absolute = unified.startsWith("/") || /^[A-Za-z]:/.test(unified);
441
+
442
+ const out: string[] = [];
443
+ for (const part of unified.split("/")) {
444
+ if (part === "" || part === ".") continue;
445
+ if (part === ".." && out.length > 0 && out.at(-1) !== "..") {
446
+ out.pop();
447
+ continue;
448
+ }
449
+ out.push(part);
450
+ }
451
+
452
+ const joined = out.join(windows ? sep : "/");
453
+ if (!absolute) return joined;
454
+ return /^[A-Za-z]:/.test(unified) ? joined : `/${joined}`;
455
+ }
456
+
457
+ /**
458
+ * Decide what a path points at.
459
+ *
460
+ * @param raw - The path as written, after quote removal
461
+ * @param cwd - The working directory the command will run in
462
+ * @param home - The user's home directory
463
+ * @param tmpdir - The value of `TMPDIR`, when set
464
+ * @returns The path with its resolution and classification
465
+ */
466
+ export function classifyPath(
467
+ raw: string,
468
+ cwd: string,
469
+ home: string,
470
+ tmpdir: string | undefined,
471
+ ): AnalyzedPath {
472
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || /^[\w.-]+@[\w.-]+:/.test(raw)) {
473
+ return { value: raw, resolved: raw, class: "remote", variableRooted: false };
474
+ }
475
+
476
+ let expanded = raw;
477
+ let hadVariable = false;
478
+
479
+ expanded = expanded.replace(/\$\{?TMPDIR(:-[^}]*)?\}?/g, () => {
480
+ return tmpdir ?? "/tmp";
481
+ });
482
+ expanded = expanded.replace(/%TEMP%|%TMP%/gi, () => tmpdir ?? "/tmp");
483
+ expanded = expanded.replace(/\$\{?HOME\}?/g, home);
484
+ if (expanded.startsWith("~")) expanded = home + expanded.slice(1);
485
+
486
+ if (/\$\{?[A-Za-z_]/.test(expanded)) hadVariable = true;
487
+
488
+ const hasGlob = /[*?]|\[[^\]]+\]/.test(expanded);
489
+ const variableRooted = /\$|%[A-Za-z_]+%/.test(raw);
490
+
491
+ const resolved = normalizeDots(expanded);
492
+ const lower = resolved.toLowerCase().replace(/\\/g, "/");
493
+
494
+ const isTemp =
495
+ /^\/tmp(\/|$)/.test(lower) ||
496
+ /^\/var\/tmp(\/|$)/.test(lower) ||
497
+ /^\/private\/var\/folders\//.test(lower) ||
498
+ /\/appdata\/local\/temp(\/|$)/.test(lower) ||
499
+ /^[a-z]:\/windows\/temp(\/|$)/.test(lower) ||
500
+ (tmpdir !== undefined && lower.startsWith(tmpdir.toLowerCase().replace(/\\/g, "/")));
501
+
502
+ if (isTemp) return { value: raw, resolved, class: "temp", variableRooted };
503
+
504
+ if (hadVariable) {
505
+ return { value: raw, resolved: undefined, class: "unresolved-variable", variableRooted };
506
+ }
507
+
508
+ if (resolved === "/" || resolved === "") {
509
+ return { value: raw, resolved, class: "filesystem-root", variableRooted };
510
+ }
511
+
512
+ if (/^\/(etc|usr|bin|sbin|boot|sys|proc|lib|opt|var|root)(\/|$)/.test(lower)) {
513
+ return { value: raw, resolved, class: "system", variableRooted };
514
+ }
515
+ if (/^[a-z]:\/(windows|program files)/.test(lower)) {
516
+ return { value: raw, resolved, class: "system", variableRooted };
517
+ }
518
+
519
+ const cwdNorm = normalizeDots(cwd).replace(/\\/g, "/").toLowerCase();
520
+ if (lower === cwdNorm || lower.startsWith(`${cwdNorm}/`)) {
521
+ return { value: raw, resolved, class: "inside-working-directory", variableRooted };
522
+ }
523
+
524
+ const homeNorm = normalizeDots(home).replace(/\\/g, "/").toLowerCase();
525
+ if (lower === homeNorm) return { value: raw, resolved, class: "home-root", variableRooted };
526
+ if (lower.startsWith(`${homeNorm}/`)) {
527
+ return { value: raw, resolved, class: "home", variableRooted };
528
+ }
529
+
530
+ if (hasGlob) return { value: raw, resolved, class: "glob", variableRooted };
531
+ if (!resolved.startsWith("/") && !/^[A-Za-z]:/.test(resolved)) {
532
+ return { value: raw, resolved, class: "relative", variableRooted };
533
+ }
534
+
535
+ return { value: raw, resolved, class: "unknown", variableRooted };
536
+ }
537
+
538
+ /**
539
+ * Whether a token looks like a path rather than a flag or a bare word.
540
+ *
541
+ * Deliberately inclusive: a token that is not a path costs one noisy state
542
+ * entry, while a missed path costs a misclassified command.
543
+ *
544
+ * @param token - An argument token
545
+ * @returns `true` when the token is worth classifying as a path
546
+ */
547
+ function looksLikePath(token: string): boolean {
548
+ if (token.startsWith("-")) return false;
549
+ if (token === "") return false;
550
+ return (
551
+ token.includes("/") ||
552
+ token.includes("\\") ||
553
+ token.startsWith("~") ||
554
+ token.startsWith("$") ||
555
+ /^[A-Za-z]:/.test(token) ||
556
+ /\.[A-Za-z0-9]{1,5}$/.test(token)
557
+ );
558
+ }
559
+
560
+ /**
561
+ * Analyze a command without executing it.
562
+ *
563
+ * Everything here is deterministic string work that the model would otherwise
564
+ * have to do by eye: splitting compound commands, removing quotes, finding the
565
+ * real program name, and resolving paths.
566
+ *
567
+ * @param command - The raw command line
568
+ * @param cwd - The working directory it will run in
569
+ * @param home - The user's home directory
570
+ * @param tmpdir - The value of `TMPDIR`, when set
571
+ * @returns The analysis handed to the model as state
572
+ */
573
+ export function analyze(
574
+ command: string,
575
+ cwd: string,
576
+ home: string,
577
+ tmpdir: string | undefined,
578
+ ): CommandAnalysis {
579
+ let parsedCleanly = true;
580
+
581
+ const { heredocs, stripped } = extractHeredocs(command);
582
+ const substitutions = extractSubstitutions(stripped);
583
+
584
+ // Substitutions are pulled out before segmenting so their inner operators
585
+ // don't split the outer command.
586
+ const withoutSubs = stripped
587
+ .replace(/\$\([^()]*\)/g, "SUBST")
588
+ .replace(/`[^`]*`/g, "SUBST");
589
+
590
+ const segments: Segment[] = [];
591
+ for (const raw of splitSegments(withoutSubs)) {
592
+ const { tokens, unterminated } = tokenize(raw);
593
+ if (unterminated) parsedCleanly = false;
594
+ if (tokens.length === 0) {
595
+ parsedCleanly = false;
596
+ continue;
597
+ }
598
+ const { argv0, wrappers, args } = resolveArgv0(tokens);
599
+ if (argv0 === undefined) parsedCleanly = false;
600
+
601
+ const paths = args
602
+ .filter(looksLikePath)
603
+ .map((t) => classifyPath(t, cwd, home, tmpdir));
604
+
605
+ segments.push({ raw, argv0, wrappers, args, paths });
606
+ }
607
+
608
+ if (segments.length === 0) parsedCleanly = false;
609
+
610
+ const staticallyUnresolvable =
611
+ !parsedCleanly ||
612
+ substitutions.length > 0 ||
613
+ segments.some((seg) =>
614
+ seg.paths.some(
615
+ (p) => p.variableRooted || p.class === "glob" || p.class === "unresolved-variable",
616
+ ),
617
+ ) ||
618
+ segments.some((seg) => seg.argv0 !== undefined && /[*?$]/.test(seg.argv0));
619
+
620
+ return { segments, substitutions, heredocs, parsedCleanly, staticallyUnresolvable };
621
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bun
2
+ import { guard } from "./guard.ts";
3
+
4
+ /**
5
+ * Judge a single command from the terminal and print the verdict with the
6
+ * judgments behind it.
7
+ *
8
+ * Usage: `bun run judge "git reset --hard"` — optionally with `--cwd=<path>`.
9
+ */
10
+ async function main(): Promise<void> {
11
+ const args = Bun.argv.slice(2);
12
+ const cwdArg = args.find((a) => a.startsWith("--cwd="));
13
+ const command = args.filter((a) => !a.startsWith("--")).join(" ");
14
+
15
+ if (command.trim() === "") {
16
+ console.error('Usage: bun run judge "<command>" [--cwd=<path>]');
17
+ process.exit(2);
18
+ }
19
+
20
+ const cwd = cwdArg?.slice("--cwd=".length) ?? process.cwd();
21
+ const verdict = await guard(command, cwd, "cli");
22
+
23
+ const mark = { allow: "✓", ask: "?", deny: "✗" }[verdict.decision];
24
+ console.log(`${mark} ${verdict.decision.toUpperCase()} ${verdict.reason}`);
25
+
26
+ if (verdict.judgments !== undefined) {
27
+ const j = verdict.judgments;
28
+ console.log("");
29
+ console.log(` executes destruction ${j.executesDestruction.toFixed(3)}`);
30
+ console.log(` unrecoverable ${j.unrecoverable.toFixed(3)}`);
31
+ console.log(` shared infrastructure ${j.targetsSharedInfrastructure.toFixed(3)}`);
32
+ console.log(` blast radius ${j.blastRadius.toFixed(2)}/3 (confidence ${j.blastRadiusConfidence.toFixed(2)})`);
33
+ }
34
+
35
+ console.log("");
36
+ console.log(
37
+ ` ${verdict.latencyMs}ms${verdict.usage ? `, ${verdict.usage.inputTokens} in / ${verdict.usage.outputTokens} out tokens` : ""}`,
38
+ );
39
+ }
40
+
41
+ await main();