procband 0.1.0 → 0.2.1
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/README.md +3 -2
- package/dist/index.d.mts +13 -4
- package/dist/index.mjs +69 -27
- package/docs/context.md +25 -3
- package/examples/sequenced-processes.ts +29 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
`procband` supervises subprocesses from TypeScript. It prefixes child output,
|
|
6
6
|
matches future log lines, waits for terminal exit, optionally restarts failed
|
|
7
|
-
processes,
|
|
7
|
+
processes, tree-kills descendants during shutdown, and propagates unobserved
|
|
8
|
+
terminal failures to the parent process.
|
|
8
9
|
|
|
9
10
|
## Installation
|
|
10
11
|
|
|
@@ -19,7 +20,6 @@ import process from 'node:process'
|
|
|
19
20
|
import { supervise } from 'procband'
|
|
20
21
|
|
|
21
22
|
const proc = supervise({
|
|
22
|
-
name: 'api',
|
|
23
23
|
command: process.execPath,
|
|
24
24
|
args: ['-e', 'console.log("ready")'],
|
|
25
25
|
})
|
|
@@ -34,4 +34,5 @@ console.log(result)
|
|
|
34
34
|
- Concepts and lifecycle: [docs/context.md](docs/context.md)
|
|
35
35
|
- Minimal usage example: [examples/basic-usage.ts](examples/basic-usage.ts)
|
|
36
36
|
- Restart example: [examples/restart-on-failure.ts](examples/restart-on-failure.ts)
|
|
37
|
+
- Sequencing example: [examples/sequenced-processes.ts](examples/sequenced-processes.ts)
|
|
37
38
|
- Exact exported signatures: [dist/index.d.mts](dist/index.d.mts)
|
package/dist/index.d.mts
CHANGED
|
@@ -27,8 +27,10 @@ type MatchStream = 'stdout' | 'stderr' | 'both';
|
|
|
27
27
|
interface ProcessConfig {
|
|
28
28
|
/**
|
|
29
29
|
* Stable identifier used in prefixed output and emitted match events.
|
|
30
|
+
*
|
|
31
|
+
* Defaults to the trailing `/[-\w]+$/` match from `command`.
|
|
30
32
|
*/
|
|
31
|
-
name
|
|
33
|
+
name?: string;
|
|
32
34
|
/**
|
|
33
35
|
* Executable or shell-free command name passed to `spawn()`.
|
|
34
36
|
*/
|
|
@@ -190,6 +192,13 @@ interface ProcessResult {
|
|
|
190
192
|
* Final child exit code, or `null` when the process exited by signal.
|
|
191
193
|
*/
|
|
192
194
|
code: number | null;
|
|
195
|
+
/**
|
|
196
|
+
* Shell-style exit status for the final process outcome.
|
|
197
|
+
*
|
|
198
|
+
* This is equal to `code` for normal exits, or `128 + signalNumber` when the
|
|
199
|
+
* process exited by signal.
|
|
200
|
+
*/
|
|
201
|
+
exitCode: number;
|
|
193
202
|
/**
|
|
194
203
|
* Final terminating signal, or `null` when the process exited normally.
|
|
195
204
|
*/
|
|
@@ -258,14 +267,14 @@ interface ProcbandProcess extends ChildProcess, PromiseLike<ProcessResult> {
|
|
|
258
267
|
/**
|
|
259
268
|
* Start supervising a single subprocess.
|
|
260
269
|
*
|
|
261
|
-
* The returned `
|
|
270
|
+
* The returned `ProcbandProcess` starts immediately, prefixes child output,
|
|
262
271
|
* exposes line-based matching helpers, and resolves when the process reaches a
|
|
263
272
|
* terminal state with no further restart pending.
|
|
264
273
|
*
|
|
265
274
|
* @param config The subprocess configuration to supervise.
|
|
266
275
|
* @returns A `ChildProcess`-compatible wrapper with procband-specific helpers.
|
|
267
|
-
* @throws When `config` is invalid, such as a missing `
|
|
268
|
-
* `
|
|
276
|
+
* @throws When `config` is invalid, such as a missing `command`, a `command`
|
|
277
|
+
* that does not produce a fallback `name`, or an invalid reserved color.
|
|
269
278
|
* @example
|
|
270
279
|
* ```ts
|
|
271
280
|
* import process from 'node:process'
|
package/dist/index.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
|
+
import { constants } from "node:os";
|
|
3
4
|
import process from "node:process";
|
|
4
5
|
import ansiStyles from "ansi-styles";
|
|
5
6
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
6
|
-
import treeKill from "@alloc/tree-kill";
|
|
7
|
+
import treeKill, { treeKillSync } from "@alloc/tree-kill";
|
|
7
8
|
//#region src/colors.ts
|
|
8
9
|
const prefixPalette = [
|
|
9
10
|
[
|
|
@@ -270,7 +271,7 @@ function isFailedExit(code, signal) {
|
|
|
270
271
|
//#region src/shutdown.ts
|
|
271
272
|
const liveTargets = /* @__PURE__ */ new Set();
|
|
272
273
|
let parentCleanupInstalled = false;
|
|
273
|
-
let
|
|
274
|
+
let handlingSignal = false;
|
|
274
275
|
function registerCleanupTarget(target) {
|
|
275
276
|
liveTargets.add(target);
|
|
276
277
|
installParentCleanup();
|
|
@@ -280,7 +281,9 @@ function unregisterCleanupTarget(target) {
|
|
|
280
281
|
if (liveTargets.size === 0) uninstallParentCleanup();
|
|
281
282
|
}
|
|
282
283
|
function killTreeBestEffort(child, signal) {
|
|
283
|
-
|
|
284
|
+
try {
|
|
285
|
+
treeKillSync(child, signal);
|
|
286
|
+
} catch {}
|
|
284
287
|
}
|
|
285
288
|
async function stopChildTree(child, close, isClosed, signal, killAfterMs) {
|
|
286
289
|
try {
|
|
@@ -301,29 +304,38 @@ function installParentCleanup() {
|
|
|
301
304
|
if (parentCleanupInstalled) return;
|
|
302
305
|
parentCleanupInstalled = true;
|
|
303
306
|
process.on("SIGINT", onParentSigint);
|
|
307
|
+
process.on("SIGTERM", onParentSigterm);
|
|
304
308
|
process.on("exit", onParentExit);
|
|
305
309
|
}
|
|
306
310
|
function uninstallParentCleanup() {
|
|
307
311
|
if (!parentCleanupInstalled) return;
|
|
308
312
|
parentCleanupInstalled = false;
|
|
309
313
|
process.off("SIGINT", onParentSigint);
|
|
314
|
+
process.off("SIGTERM", onParentSigterm);
|
|
310
315
|
process.off("exit", onParentExit);
|
|
311
316
|
}
|
|
312
317
|
function onParentExit() {
|
|
313
318
|
for (const target of [...liveTargets]) target.cleanupFromExit();
|
|
314
319
|
}
|
|
315
320
|
function onParentSigint() {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
321
|
+
onParentSignal("SIGINT");
|
|
322
|
+
}
|
|
323
|
+
function onParentSigterm() {
|
|
324
|
+
onParentSignal("SIGTERM");
|
|
325
|
+
}
|
|
326
|
+
function onParentSignal(signal) {
|
|
327
|
+
if (handlingSignal) return;
|
|
328
|
+
handlingSignal = true;
|
|
329
|
+
const handler = signal === "SIGINT" ? onParentSigint : onParentSigterm;
|
|
330
|
+
const pending = [...liveTargets].map((target) => target.cleanupFromSignal(signal));
|
|
319
331
|
Promise.allSettled(pending).finally(() => {
|
|
320
|
-
|
|
321
|
-
process.off(
|
|
322
|
-
if (process.listenerCount(
|
|
323
|
-
process.kill(process.pid,
|
|
332
|
+
handlingSignal = false;
|
|
333
|
+
process.off(signal, handler);
|
|
334
|
+
if (process.listenerCount(signal) === 0) {
|
|
335
|
+
process.kill(process.pid, signal);
|
|
324
336
|
return;
|
|
325
337
|
}
|
|
326
|
-
process.on(
|
|
338
|
+
process.on(signal, handler);
|
|
327
339
|
});
|
|
328
340
|
}
|
|
329
341
|
function isMissingProcessError(error) {
|
|
@@ -334,14 +346,14 @@ function isMissingProcessError(error) {
|
|
|
334
346
|
/**
|
|
335
347
|
* Start supervising a single subprocess.
|
|
336
348
|
*
|
|
337
|
-
* The returned `
|
|
349
|
+
* The returned `ProcbandProcess` starts immediately, prefixes child output,
|
|
338
350
|
* exposes line-based matching helpers, and resolves when the process reaches a
|
|
339
351
|
* terminal state with no further restart pending.
|
|
340
352
|
*
|
|
341
353
|
* @param config The subprocess configuration to supervise.
|
|
342
354
|
* @returns A `ChildProcess`-compatible wrapper with procband-specific helpers.
|
|
343
|
-
* @throws When `config` is invalid, such as a missing `
|
|
344
|
-
* `
|
|
355
|
+
* @throws When `config` is invalid, such as a missing `command`, a `command`
|
|
356
|
+
* that does not produce a fallback `name`, or an invalid reserved color.
|
|
345
357
|
* @example
|
|
346
358
|
* ```ts
|
|
347
359
|
* import process from 'node:process'
|
|
@@ -376,15 +388,17 @@ var ProcbandProcessImpl = class extends EventEmitter {
|
|
|
376
388
|
stderrSinkActive = true;
|
|
377
389
|
stderrSinkCleanup = null;
|
|
378
390
|
stopPromise = null;
|
|
391
|
+
shutdownRequested = false;
|
|
379
392
|
restartDisabled = false;
|
|
380
393
|
finalized = false;
|
|
381
394
|
lastResult = null;
|
|
395
|
+
terminalResultObserved = false;
|
|
382
396
|
constructor(config) {
|
|
383
397
|
super();
|
|
384
|
-
validateProcessConfig(config);
|
|
398
|
+
const name = validateProcessConfig(config);
|
|
385
399
|
this.config = config;
|
|
386
|
-
this.name =
|
|
387
|
-
this.label = config.label ??
|
|
400
|
+
this.name = name;
|
|
401
|
+
this.label = config.label ?? name;
|
|
388
402
|
this.color = resolveProcessColor(config.color);
|
|
389
403
|
this.matches = new MatchRegistry(this.name);
|
|
390
404
|
this.restart = new RestartController(config.restart);
|
|
@@ -395,6 +409,7 @@ var ProcbandProcessImpl = class extends EventEmitter {
|
|
|
395
409
|
this.bindStderrSink();
|
|
396
410
|
this.spawnAttempt();
|
|
397
411
|
registerCleanupTarget(this);
|
|
412
|
+
liveProcesses.add(this);
|
|
398
413
|
}
|
|
399
414
|
get pid() {
|
|
400
415
|
return this.currentChild?.pid;
|
|
@@ -443,17 +458,13 @@ var ProcbandProcessImpl = class extends EventEmitter {
|
|
|
443
458
|
return this.matches.waitFor(pattern, options);
|
|
444
459
|
}
|
|
445
460
|
wait() {
|
|
461
|
+
this.markTerminalResultObserved();
|
|
446
462
|
return this.finalPromise;
|
|
447
463
|
}
|
|
448
464
|
then(onfulfilled, onrejected) {
|
|
465
|
+
this.markTerminalResultObserved();
|
|
449
466
|
return this.finalPromise.then(onfulfilled, onrejected);
|
|
450
467
|
}
|
|
451
|
-
catch(onrejected) {
|
|
452
|
-
return this.finalPromise.catch(onrejected);
|
|
453
|
-
}
|
|
454
|
-
finally(onfinally) {
|
|
455
|
-
return this.finalPromise.finally(onfinally);
|
|
456
|
-
}
|
|
457
468
|
kill(signal) {
|
|
458
469
|
const child = this.currentChild;
|
|
459
470
|
if (!child) return false;
|
|
@@ -472,26 +483,29 @@ var ProcbandProcessImpl = class extends EventEmitter {
|
|
|
472
483
|
}
|
|
473
484
|
async stop(options) {
|
|
474
485
|
if (this.stopPromise) return this.stopPromise;
|
|
486
|
+
this.shutdownRequested = true;
|
|
475
487
|
this.restartDisabled = true;
|
|
476
488
|
this.restart.cancelDelay();
|
|
477
489
|
this.stopPromise = this.stopActiveTree(options?.signal ?? "SIGTERM", options?.killAfterMs ?? 5e3);
|
|
478
490
|
return this.stopPromise;
|
|
479
491
|
}
|
|
480
492
|
cleanupFromExit() {
|
|
493
|
+
this.shutdownRequested = true;
|
|
481
494
|
this.restartDisabled = true;
|
|
482
495
|
this.restart.cancelDelay();
|
|
483
496
|
const child = this.currentChild;
|
|
484
497
|
if (child) killTreeBestEffort(child);
|
|
485
498
|
}
|
|
486
|
-
async
|
|
499
|
+
async cleanupFromSignal(signal) {
|
|
500
|
+
this.shutdownRequested = true;
|
|
487
501
|
this.restartDisabled = true;
|
|
488
502
|
this.restart.cancelDelay();
|
|
489
|
-
await this.stopActiveTree(
|
|
503
|
+
await this.stopActiveTree(signal, 1e3);
|
|
490
504
|
}
|
|
491
505
|
async stopActiveTree(signal, killAfterMs) {
|
|
492
506
|
const attempt = this.attempt;
|
|
493
507
|
if (!attempt || attempt.closed) {
|
|
494
|
-
await this.
|
|
508
|
+
await this.finalPromise;
|
|
495
509
|
return;
|
|
496
510
|
}
|
|
497
511
|
await stopChildTree(attempt.child, attempt.close, () => attempt.closed, signal, killAfterMs);
|
|
@@ -602,6 +616,7 @@ var ProcbandProcessImpl = class extends EventEmitter {
|
|
|
602
616
|
return {
|
|
603
617
|
name: this.name,
|
|
604
618
|
code,
|
|
619
|
+
exitCode: getResultExitCode(code, signal),
|
|
605
620
|
signal,
|
|
606
621
|
restarts: this.restart.restarts,
|
|
607
622
|
restartSuppressed: this.restart.restartSuppressed
|
|
@@ -612,11 +627,20 @@ var ProcbandProcessImpl = class extends EventEmitter {
|
|
|
612
627
|
this.finalized = true;
|
|
613
628
|
this.restart.cancelDelay();
|
|
614
629
|
this.lastResult = result;
|
|
630
|
+
liveProcesses.delete(this);
|
|
631
|
+
if (liveProcesses.size === 0) propagatedFailure = false;
|
|
632
|
+
if (this.shouldPropagateFailure(result)) propagateFailure(result.exitCode);
|
|
615
633
|
this.matches.close(/* @__PURE__ */ new Error(`Process "${this.name}" exited before a matching line was observed`));
|
|
616
634
|
this.unbindStderrSink();
|
|
617
635
|
unregisterCleanupTarget(this);
|
|
618
636
|
this.finalResolve(result);
|
|
619
637
|
}
|
|
638
|
+
markTerminalResultObserved() {
|
|
639
|
+
this.terminalResultObserved = true;
|
|
640
|
+
}
|
|
641
|
+
shouldPropagateFailure(result) {
|
|
642
|
+
return result.exitCode !== 0 && !this.shutdownRequested && !this.terminalResultObserved;
|
|
643
|
+
}
|
|
620
644
|
bindStderrSink() {
|
|
621
645
|
const sink = this.stderrSink;
|
|
622
646
|
if (!sink || typeof sink.on !== "function") return;
|
|
@@ -659,10 +683,28 @@ var ProcbandProcessImpl = class extends EventEmitter {
|
|
|
659
683
|
this.handleLine(stream, line, false);
|
|
660
684
|
}
|
|
661
685
|
};
|
|
686
|
+
const liveProcesses = /* @__PURE__ */ new Set();
|
|
687
|
+
let propagatedFailure = false;
|
|
662
688
|
function validateProcessConfig(config) {
|
|
663
|
-
if (!config.name) throw new Error("ProcessConfig.name is required");
|
|
664
689
|
if (!config.command) throw new Error("ProcessConfig.command is required");
|
|
665
690
|
validateProcessColor(config.color);
|
|
691
|
+
const name = config.name || inferProcessName(config.command);
|
|
692
|
+
if (!name) throw new Error("ProcessConfig.name is required when command does not match /[-\\w]+$/");
|
|
693
|
+
return name;
|
|
694
|
+
}
|
|
695
|
+
function inferProcessName(command) {
|
|
696
|
+
return command.match(/[-\w]+$/)?.[0];
|
|
697
|
+
}
|
|
698
|
+
function getResultExitCode(code, signal) {
|
|
699
|
+
if (code != null) return code;
|
|
700
|
+
if (signal) return 128 + (constants.signals[signal] ?? 0);
|
|
701
|
+
return 1;
|
|
702
|
+
}
|
|
703
|
+
function propagateFailure(exitCode) {
|
|
704
|
+
if (propagatedFailure) return;
|
|
705
|
+
propagatedFailure = true;
|
|
706
|
+
if (process.exitCode == null || process.exitCode === 0) process.exitCode = exitCode;
|
|
707
|
+
for (const proc of liveProcesses) proc.stop().catch(() => {});
|
|
666
708
|
}
|
|
667
709
|
//#endregion
|
|
668
710
|
export { supervise };
|
package/docs/context.md
CHANGED
|
@@ -13,6 +13,7 @@ Supervision adds four behaviors on top of raw `spawn()`:
|
|
|
13
13
|
- line-based matching for future output
|
|
14
14
|
- optional restart policy with failure suppression
|
|
15
15
|
- tree-aware shutdown for the child and its descendants
|
|
16
|
+
- parent-exit propagation for unobserved terminal failures
|
|
16
17
|
|
|
17
18
|
# When to Use
|
|
18
19
|
|
|
@@ -55,7 +56,10 @@ Supervision adds four behaviors on top of raw `spawn()`:
|
|
|
55
56
|
`waitFor()`.
|
|
56
57
|
6. When a child exits, `procband` either finalizes or starts a new attempt,
|
|
57
58
|
depending on the restart policy.
|
|
58
|
-
7.
|
|
59
|
+
7. A terminal failed exit that nobody observed through `await proc` or
|
|
60
|
+
`proc.wait()` sets `process.exitCode` and begins
|
|
61
|
+
stopping any other live `procband` processes in the same parent script.
|
|
62
|
+
8. `await proc` or `await proc.wait()` resolves only after the process is
|
|
59
63
|
terminal and no further restart will happen.
|
|
60
64
|
|
|
61
65
|
# Common Tasks -> Recommended APIs
|
|
@@ -68,6 +72,10 @@ Supervision adds four behaviors on top of raw `spawn()`:
|
|
|
68
72
|
`proc.stop()`
|
|
69
73
|
- Inspect final exit state:
|
|
70
74
|
`await proc` or `await proc.wait()`
|
|
75
|
+
- Take ownership of a process failure:
|
|
76
|
+
`await proc` or `await proc.wait()`
|
|
77
|
+
- Let an unobserved terminal failure fail the parent script:
|
|
78
|
+
Do not call `wait()` or await the thenable result
|
|
71
79
|
- Capture raw child `stderr` in a file or custom stream:
|
|
72
80
|
`ProcessConfig.stderr`
|
|
73
81
|
- Retry failed exits with sane defaults:
|
|
@@ -85,22 +93,36 @@ Supervision adds four behaviors on top of raw `spawn()`:
|
|
|
85
93
|
observed.
|
|
86
94
|
- `await proc` resolves for both successful and failed exits. Inspect the
|
|
87
95
|
returned `ProcessResult`.
|
|
96
|
+
- `ProcessResult.exitCode` exposes the shell-style exit status for the final
|
|
97
|
+
outcome, including signal exits.
|
|
98
|
+
- Calling `proc.wait()` or awaiting the thenable process marks its terminal
|
|
99
|
+
result as observed and suppresses default parent-exit propagation.
|
|
100
|
+
- An unobserved terminal failure sets `process.exitCode` to the first failing
|
|
101
|
+
process's `ProcessResult.exitCode` and starts stopping other live `procband`
|
|
102
|
+
processes in the same parent script.
|
|
88
103
|
- The wrapper survives restarts, but inherited `pid`, `stdin`, `stdout`,
|
|
89
104
|
`stderr`, and related `ChildProcess` fields always refer to the current active
|
|
90
105
|
child attempt.
|
|
91
106
|
- `kill()` only signals the current direct child. `stop()` disables restart and
|
|
92
107
|
kills the full process tree.
|
|
108
|
+
- Parent cleanup installs both `SIGINT` and `SIGTERM` handlers while any live
|
|
109
|
+
supervised process exists.
|
|
93
110
|
- `stderr` prefixes always use the reserved red, even when a custom process
|
|
94
111
|
color is configured.
|
|
112
|
+
- `ProcessConfig.name` is optional. When omitted, it falls back to the
|
|
113
|
+
trailing `/[-\w]+$/` match from `command`.
|
|
95
114
|
|
|
96
115
|
# Error Model
|
|
97
116
|
|
|
98
|
-
- `supervise()` throws synchronously for invalid config such as missing
|
|
99
|
-
|
|
117
|
+
- `supervise()` throws synchronously for invalid config such as missing
|
|
118
|
+
`command`, a `command` that does not produce a fallback `name`, or an
|
|
119
|
+
invalid reserved color.
|
|
100
120
|
- `waitFor()` rejects on timeout or terminal exit before a future match.
|
|
101
121
|
- A thrown `match()` callback only unsubscribes that callback.
|
|
102
122
|
- Errors from `ProcessConfig.stderr` stop teeing to that sink but do not stop
|
|
103
123
|
supervision.
|
|
124
|
+
- Unobserved terminal failures do not reject promises. They set the parent
|
|
125
|
+
`process.exitCode` and start stopping sibling `procband` processes.
|
|
104
126
|
- `stop()` may reject if tree-kill fails with a non-`ESRCH` error.
|
|
105
127
|
|
|
106
128
|
# Terminology
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
import { supervise } from 'procband'
|
|
3
|
+
|
|
4
|
+
const api = supervise({
|
|
5
|
+
name: 'api',
|
|
6
|
+
command: process.execPath,
|
|
7
|
+
args: [
|
|
8
|
+
'-e',
|
|
9
|
+
[
|
|
10
|
+
'console.log("booting")',
|
|
11
|
+
'setTimeout(() => console.log("ready"), 20)',
|
|
12
|
+
'setInterval(() => {}, 1000)',
|
|
13
|
+
].join(';'),
|
|
14
|
+
],
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
await api.waitFor('ready')
|
|
18
|
+
|
|
19
|
+
supervise({
|
|
20
|
+
name: 'worker',
|
|
21
|
+
command: process.execPath,
|
|
22
|
+
args: [
|
|
23
|
+
'-e',
|
|
24
|
+
[
|
|
25
|
+
'console.log("watching")',
|
|
26
|
+
'setTimeout(() => process.exit(1), 60)',
|
|
27
|
+
].join(';'),
|
|
28
|
+
],
|
|
29
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "procband",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Supervise subprocesses from TypeScript: prefix logs, wait for output patterns, and manage lifecycle/restarts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alec Larson",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"vitest": "^4.1.2"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@alloc/tree-kill": "^1.
|
|
30
|
+
"@alloc/tree-kill": "^1.4.0",
|
|
31
31
|
"ansi-styles": "^6.2.3"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|