@intentius/chant 0.18.27 → 0.18.28

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.
@@ -4,4 +4,14 @@ import { type ParsedArgs } from "./registry.js";
4
4
  * Parse command line arguments
5
5
  */
6
6
  export declare function parseArgs(args: string[]): ParsedArgs;
7
+ /**
8
+ * Wait until a writable stream has flushed its buffer. `process.exit()` discards
9
+ * data still buffered for an async sink (a pipe or file), truncating large output
10
+ * at the ~64 KB pipe buffer — so `chant graph --format ir` piped into a consumer
11
+ * loses everything past 64 KB and its JSON won't parse. A TTY writes
12
+ * synchronously (`writableLength` stays 0), so this is a no-op there. Resolves on
13
+ * `error`/`close` too, so a reader that closes early (EPIPE) can't hang exit.
14
+ * Exported for testing.
15
+ */
16
+ export declare function waitForStreamDrain(stream: NodeJS.WriteStream): Promise<void>;
7
17
  //# sourceMappingURL=main.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":";AAMA,OAAO,EAAmC,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAkB9E;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CA2LpD"}
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":";AAMA,OAAO,EAAmC,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAkB9E;;GAEG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,CA2LpD;AAsVD;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAuB5E"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.18.27",
3
+ "version": "0.18.28",
4
4
  "description": "Declarative infrastructure-as-code toolkit \u2014 TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { parseArgs } from "./main";
2
+ import { EventEmitter } from "node:events";
3
+ import { parseArgs, waitForStreamDrain } from "./main";
3
4
  import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
4
5
 
5
6
  describe("parseArgs", () => {
@@ -416,3 +417,50 @@ describe("parseArgs — run flags", () => {
416
417
  expect(result.extraPositional2).toBe("gate-dns");
417
418
  });
418
419
  });
420
+
421
+ describe("waitForStreamDrain", () => {
422
+ // Minimal writable stub — just the surface waitForStreamDrain reads.
423
+ function fakeStream(len: number): NodeJS.WriteStream & { writableLength: number } {
424
+ const s = new EventEmitter() as unknown as NodeJS.WriteStream & { writableLength: number };
425
+ s.writableLength = len;
426
+ (s as { writableEnded: boolean }).writableEnded = false;
427
+ (s as { destroyed: boolean }).destroyed = false;
428
+ return s;
429
+ }
430
+
431
+ test("resolves immediately when nothing is buffered (e.g. a TTY)", async () => {
432
+ await expect(waitForStreamDrain(fakeStream(0))).resolves.toBeUndefined();
433
+ });
434
+
435
+ test("waits through drains until the buffer is actually empty", async () => {
436
+ const s = fakeStream(1000);
437
+ let done = false;
438
+ const p = waitForStreamDrain(s).then(() => (done = true));
439
+ await Promise.resolve();
440
+ expect(done).toBe(false);
441
+ // A drain while still buffered must NOT resolve (large one-shot write, kernel
442
+ // took a slice, more remains) — it re-arms.
443
+ s.emit("drain");
444
+ await Promise.resolve();
445
+ expect(done).toBe(false);
446
+ // Fully flushed now.
447
+ s.writableLength = 0;
448
+ s.emit("drain");
449
+ await p;
450
+ expect(done).toBe(true);
451
+ });
452
+
453
+ test("resolves on error so a reader that closed early (EPIPE) can't hang exit", async () => {
454
+ const s = fakeStream(500);
455
+ const p = waitForStreamDrain(s);
456
+ s.emit("error", new Error("EPIPE"));
457
+ await expect(p).resolves.toBeUndefined();
458
+ });
459
+
460
+ test("resolves on close as well", async () => {
461
+ const s = fakeStream(500);
462
+ const p = waitForStreamDrain(s);
463
+ s.emit("close");
464
+ await expect(p).resolves.toBeUndefined();
465
+ });
466
+ });
package/src/cli/main.ts CHANGED
@@ -551,7 +551,48 @@ async function main(): Promise<void> {
551
551
  const serializers = plugins.map((p) => p.serializer);
552
552
  const ctx = { args, plugins, serializers };
553
553
 
554
- process.exit(await match.def.handler(ctx));
554
+ await flushAndExit(await match.def.handler(ctx));
555
+ }
556
+
557
+ /**
558
+ * Wait until a writable stream has flushed its buffer. `process.exit()` discards
559
+ * data still buffered for an async sink (a pipe or file), truncating large output
560
+ * at the ~64 KB pipe buffer — so `chant graph --format ir` piped into a consumer
561
+ * loses everything past 64 KB and its JSON won't parse. A TTY writes
562
+ * synchronously (`writableLength` stays 0), so this is a no-op there. Resolves on
563
+ * `error`/`close` too, so a reader that closes early (EPIPE) can't hang exit.
564
+ * Exported for testing.
565
+ */
566
+ export function waitForStreamDrain(stream: NodeJS.WriteStream): Promise<void> {
567
+ return new Promise((resolve) => {
568
+ const tick = (): void => {
569
+ if (stream.writableLength === 0 || stream.writableEnded || stream.destroyed) {
570
+ cleanup();
571
+ resolve();
572
+ return;
573
+ }
574
+ stream.once("drain", tick);
575
+ };
576
+ const stop = (): void => {
577
+ cleanup();
578
+ resolve();
579
+ };
580
+ const cleanup = (): void => {
581
+ stream.off("drain", tick);
582
+ stream.off("error", stop);
583
+ stream.off("close", stop);
584
+ };
585
+ stream.once("error", stop);
586
+ stream.once("close", stop);
587
+ tick();
588
+ });
589
+ }
590
+
591
+ /** Flush stdout+stderr, then exit — so a large piped payload isn't truncated. */
592
+ async function flushAndExit(code: number): Promise<never> {
593
+ await waitForStreamDrain(process.stdout);
594
+ await waitForStreamDrain(process.stderr);
595
+ process.exit(code);
555
596
  }
556
597
 
557
598
  // Only run main when executed directly, not when imported. Robust to symlinked
@@ -559,13 +600,13 @@ async function main(): Promise<void> {
559
600
  // whole CLI through the npm .bin shim / a symlinked checkout).
560
601
  const isMain = isEntryPoint(process.argv[1], import.meta.url);
561
602
  if (isMain) {
562
- main().catch((err) => {
603
+ main().catch(async (err) => {
563
604
  const verbose = process.argv.includes("--verbose") || process.argv.includes("-v");
564
605
  if (verbose && err instanceof Error && err.stack) {
565
606
  console.error(err.stack);
566
607
  } else {
567
608
  console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
568
609
  }
569
- process.exit(1);
610
+ await flushAndExit(1);
570
611
  });
571
612
  }