@akanjs/devkit 3.0.0-alpha.4 → 3.0.0-alpha.6

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.
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { AbstractDoc } from "./abstractDoc";
6
- import { AkanQualityScanner } from "./qualityScanner";
6
+ import { AkanQualityScanner, type QualityScanResult } from "./qualityScanner";
7
7
 
8
8
  const tempRoots: string[] = [];
9
9
 
@@ -44,3 +44,156 @@ describe("AkanQualityScanner abstract rule", () => {
44
44
  expect(warnings[0]?.fix).toContain("akan compact");
45
45
  });
46
46
  });
47
+
48
+ const staticMarkup = (elementNum: number) =>
49
+ Array.from({ length: elementNum }, (_, idx) => ` <p className="text-sm">row ${idx}</p>`).join("\n");
50
+
51
+ const rulesOf = (result: QualityScanResult, rule: string) => result.warnings.filter((warning) => warning.rule === rule);
52
+
53
+ describe("AkanQualityScanner ssr rules", () => {
54
+ test("flags a client file that uses no client-only capability", async () => {
55
+ const root = await makeWorkspace({
56
+ "apps/demo/ui/Plain.tsx": `"use client";\nexport const Plain = () => <div>plain</div>;\n`,
57
+ "apps/demo/ui/Interactive.tsx": `"use client";\nexport const Interactive = () => <button onClick={() => null}>go</button>;\n`,
58
+ "apps/demo/ui/Hooked.tsx": `"use client";\nimport { useState } from "react";\nexport const Hooked = () => {\n const [open] = useState(false);\n return <div>{open ? "y" : "n"}</div>;\n};\n`,
59
+ });
60
+
61
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.unnecessary-use-client");
62
+
63
+ expect(warnings).toHaveLength(1);
64
+ expect(warnings[0]?.file).toBe("apps/demo/ui/Plain.tsx");
65
+ });
66
+
67
+ test("keeps the directive on a third-party wrapper and on an index_ boundary", async () => {
68
+ const root = await makeWorkspace({
69
+ "apps/demo/ui/Chart.tsx": `"use client";\nimport { Bar } from "react-chartjs-2";\nexport const Chart = () => <Bar data={{}} />;\n`,
70
+ "apps/demo/ui/Lazy/index_.tsx": `"use client";\nexport { Inner } from "./Inner";\n`,
71
+ });
72
+
73
+ expect(rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.unnecessary-use-client")).toHaveLength(0);
74
+ });
75
+
76
+ test("flags a static component and a mostly-static component inside a client file", async () => {
77
+ const root = await makeWorkspace({
78
+ "apps/demo/ui/Panels.tsx": [
79
+ `"use client";`,
80
+ `import { useState } from "react";`,
81
+ `export const StaticPanel = () => (`,
82
+ ` <section>`,
83
+ staticMarkup(5),
84
+ ` </section>`,
85
+ `);`,
86
+ `export const MixedPanel = () => {`,
87
+ ` const [open, setOpen] = useState(false);`,
88
+ ` return (`,
89
+ ` <section>`,
90
+ staticMarkup(12),
91
+ ` <span>{open ? "open" : "shut"}</span>`,
92
+ ` </section>`,
93
+ ` );`,
94
+ `};`,
95
+ "",
96
+ ].join("\n"),
97
+ });
98
+
99
+ const result = await new AkanQualityScanner().scan(root);
100
+ const staticWarnings = rulesOf(result, "akan.ssr.client-static-component");
101
+ const mixedWarnings = rulesOf(result, "akan.ssr.client-static-markup");
102
+
103
+ expect(staticWarnings).toHaveLength(1);
104
+ expect(staticWarnings[0]?.message).toContain("StaticPanel");
105
+ expect(staticWarnings[0]?.fix).toContain("server file");
106
+ expect(mixedWarnings).toHaveLength(1);
107
+ expect(mixedWarnings[0]?.message).toContain("MixedPanel");
108
+ });
109
+
110
+ test("flags a mount-only load but not a reactive one", async () => {
111
+ const root = await makeWorkspace({
112
+ "apps/demo/lib/post/Post.Zone.tsx": [
113
+ `"use client";`,
114
+ `import { useEffect } from "react";`,
115
+ `export const List = ({ tag }: { tag: string }) => {`,
116
+ ` useEffect(() => {`,
117
+ ` void st.do.initPostInPublic();`,
118
+ ` }, []);`,
119
+ ` useEffect(() => {`,
120
+ ` void st.do.getPostListInTag(tag);`,
121
+ ` }, [tag]);`,
122
+ ` return <div>{tag}</div>;`,
123
+ `};`,
124
+ "",
125
+ ].join("\n"),
126
+ });
127
+
128
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.client-mount-load");
129
+
130
+ expect(warnings).toHaveLength(1);
131
+ expect(warnings[0]?.message).toContain("st.do.initPostInPublic");
132
+ expect(warnings[0]?.fix).toContain("init/view");
133
+ });
134
+
135
+ test("flags useState in a Template", async () => {
136
+ const root = await makeWorkspace({
137
+ "apps/demo/lib/post/Post.Template.tsx": `"use client";\nimport { useState } from "react";\nexport const General = () => {\n const [draft, setDraft] = useState("");\n return <input value={draft} onChange={(e) => setDraft(e.target.value)} />;\n};\n`,
138
+ });
139
+
140
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.template-client-state");
141
+
142
+ expect(warnings).toHaveLength(1);
143
+ expect(warnings[0]?.fix).toContain("st.do.setFieldOnX");
144
+ });
145
+
146
+ test("flags a module that renders only from client files", async () => {
147
+ const root = await makeWorkspace({
148
+ "apps/demo/lib/post/Post.Zone.tsx": [
149
+ `"use client";`,
150
+ `import { useState } from "react";`,
151
+ `export const Card = () => {`,
152
+ ` const [open] = useState(false);`,
153
+ ` return (`,
154
+ ` <section>`,
155
+ staticMarkup(14),
156
+ ` <span>{open ? "open" : "shut"}</span>`,
157
+ ` </section>`,
158
+ ` );`,
159
+ `};`,
160
+ "",
161
+ ].join("\n"),
162
+ "libs/shared/lib/user/User.Zone.tsx": `"use client";\nimport { st } from "@libs/shared/client";\nexport const Self = () => <User.View.General user={st.use.self()} />;\n`,
163
+ "libs/shared/lib/user/User.View.tsx": `export const General = ({ name }: { name: string }) => <div>{name}</div>;\n`,
164
+ });
165
+
166
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.module-missing-server-view");
167
+
168
+ expect(warnings).toHaveLength(1);
169
+ expect(warnings[0]?.message).toContain("apps/demo/lib/post");
170
+ });
171
+
172
+ test("measures the server render share per scope and for the workspace", async () => {
173
+ const root = await makeWorkspace({
174
+ "apps/demo/ui/Server.tsx": `export const Server = () => (\n <section>\n <p>a</p>\n <p>b</p>\n </section>\n);\n`,
175
+ "libs/shared/ui/Client.tsx": `"use client";\nexport const Client = () => <button onClick={() => null}>go</button>;\n`,
176
+ });
177
+
178
+ const { ssrBalance } = await new AkanQualityScanner().scan(root);
179
+
180
+ expect(ssrBalance.map((entry) => entry.scope)).toEqual(["apps/demo", "libs/shared", "workspace"]);
181
+ expect(ssrBalance[0]).toMatchObject({ serverMass: 3, clientMass: 0, serverShare: 1 });
182
+ expect(ssrBalance[1]).toMatchObject({ serverMass: 0, clientMass: 1 });
183
+ expect(ssrBalance[2]).toMatchObject({ scope: "workspace", serverMass: 3, clientMass: 1 });
184
+ });
185
+ });
186
+
187
+ describe("AkanQualityScanner layout rules", () => {
188
+ test("flags an unknown app root file but not a facet entrypoint", async () => {
189
+ const root = await makeWorkspace({
190
+ "apps/demo/client.ts": "export const client = 1;\n",
191
+ "apps/demo/helper.ts": "export const helper = 1;\n",
192
+ });
193
+
194
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.layout.app-root-file");
195
+
196
+ expect(warnings).toHaveLength(1);
197
+ expect(warnings[0]?.file).toBe("apps/demo/helper.ts");
198
+ });
199
+ });
package/qualityScanner.ts CHANGED
@@ -4,9 +4,11 @@ import path from "node:path";
4
4
  import ignore from "ignore";
5
5
  import ts from "typescript";
6
6
  import { AbstractDoc } from "./abstractDoc";
7
+ import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
8
+ import { appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
7
9
 
8
10
  type QualitySeverity = "warning";
9
- type QualityScope = "global" | "file" | "convention" | "layout";
11
+ type QualityScope = "global" | "file" | "convention" | "layout" | "ssr";
10
12
 
11
13
  export interface QualityWarning {
12
14
  rule: string;
@@ -23,10 +25,11 @@ export interface QualityScanResult {
23
25
  workspaceRoot: string;
24
26
  scannedFiles: number;
25
27
  warnings: QualityWarning[];
28
+ ssrBalance: SsrBalanceEntry[];
26
29
  suggestedRules: string[];
27
30
  }
28
31
 
29
- interface SourceFileInfo {
32
+ export interface SourceFileInfo {
30
33
  file: string;
31
34
  absolutePath: string;
32
35
  content: string;
@@ -88,29 +91,6 @@ const SUGGESTED_RULES = [
88
91
  "Avoid large mixed-purpose class files; class export files should import helpers from neighboring utility files instead of declaring them inline.",
89
92
  ];
90
93
 
91
- const APP_ROOT_FILES = new Set([
92
- "akan.app.json",
93
- "akan.config.ts",
94
- "capacitor.config.ts",
95
- "client.ts",
96
- "main.ts",
97
- "package.json",
98
- "server.ts",
99
- "tsconfig.json",
100
- ]);
101
-
102
- const LIB_ROOT_FILES = new Set([
103
- "cnst.ts",
104
- "db.ts",
105
- "dict.ts",
106
- "option.ts",
107
- "sig.ts",
108
- "srv.ts",
109
- "st.ts",
110
- "useClient.ts",
111
- "useServer.ts",
112
- ]);
113
-
114
94
  const CONVENTION_SUFFIXES = [
115
95
  ".constant.ts",
116
96
  ".dictionary.ts",
@@ -169,6 +149,18 @@ const RULE_FIXES: Record<string, string> = {
169
149
  "Move the file into a domain module folder under lib/; keep lib root limited to generated support facets.",
170
150
  "akan.layout.module-ui-file":
171
151
  "Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component.",
152
+ "akan.ssr.unnecessary-use-client":
153
+ 'Delete the "use client" directive so the file renders on the server. If it exists only to wrap one client child, drop the wrapper and use the child directly.',
154
+ "akan.ssr.client-static-component":
155
+ "Move the component to a server file — a <Model>.Unit.tsx / <Model>.View.tsx for a module, or a ui/ file with no directive — and reference it from the client file.",
156
+ "akan.ssr.client-static-markup":
157
+ "Keep the interactive element in the client component and hoist the static subtree into a server component, then accept it as `children` or render it through a Unit/View reference.",
158
+ "akan.ssr.client-mount-load":
159
+ "Load the data in the route with `fetch.initX(...)` / `fetch.viewX(...)` and pass the init/view object down as a prop; the client store hydrates from it and the effect goes away.",
160
+ "akan.ssr.module-missing-server-view":
161
+ "Add a <Model>.Unit.tsx for list/card rendering and a <Model>.View.tsx for the detail surface, then have the Zone delegate to them.",
162
+ "akan.ssr.template-client-state":
163
+ "Bind the field to the store instead: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`.",
172
164
  };
173
165
 
174
166
  function getRuleFix(rule: string): string | undefined {
@@ -190,6 +182,7 @@ export class AkanQualityScanner {
190
182
  .filter((file) => AbstractDoc.isAbstractPath(file))
191
183
  .map((file) => this.#readTextFile(workspaceRoot, file)),
192
184
  );
185
+ const ssr = new SsrScanner().scan(sourceFiles);
193
186
  const warnings = [
194
187
  ...this.#scanGlobalQuality(sourceFiles),
195
188
  ...sourceFiles.flatMap((sourceFile) => this.#scanSingleFileQuality(sourceFile)),
@@ -197,6 +190,7 @@ export class AkanQualityScanner {
197
190
  ...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
198
191
  ...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
199
192
  ...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
193
+ ...ssr.warnings,
200
194
  ];
201
195
 
202
196
  return {
@@ -205,6 +199,7 @@ export class AkanQualityScanner {
205
199
  warnings: warnings
206
200
  .map((warning) => ({ ...warning, fix: warning.fix ?? getRuleFix(warning.rule) }))
207
201
  .sort(compareWarnings),
202
+ ssrBalance: ssr.balance,
208
203
  suggestedRules: SUGGESTED_RULES,
209
204
  };
210
205
  }
@@ -432,7 +427,7 @@ export class AkanQualityScanner {
432
427
  #scanLayoutQuality(sourceFile: SourceFileInfo): QualityWarning[] {
433
428
  const segments = sourceFile.file.split("/");
434
429
  const warnings: QualityWarning[] = [];
435
- if (segments[0] === "apps" && segments.length === 3 && !APP_ROOT_FILES.has(segments[2])) {
430
+ if (segments[0] === "apps" && segments.length === 3 && !appRootAllowedFiles.has(segments[2])) {
436
431
  warnings.push({
437
432
  rule: "akan.layout.app-root-file",
438
433
  scope: "layout",
@@ -443,7 +438,7 @@ export class AkanQualityScanner {
443
438
  }
444
439
 
445
440
  const libRootFile = getLibRootFile(sourceFile.file);
446
- if (libRootFile && !LIB_ROOT_FILES.has(libRootFile)) {
441
+ if (libRootFile && !libFacetRootAllowedFiles.has(libRootFile)) {
447
442
  warnings.push({
448
443
  rule: "akan.layout.lib-root-file",
449
444
  scope: "layout",
@@ -470,6 +465,10 @@ export function formatQualityScanResult(result: QualityScanResult) {
470
465
  "",
471
466
  ...formatQualityWarnings(result.warnings),
472
467
  "",
468
+ "SSR balance (component files, JSX elements rendered per side):",
469
+ "",
470
+ ...formatSsrBalance(result.ssrBalance),
471
+ "",
473
472
  "Suggested quality rules:",
474
473
  "",
475
474
  ...result.suggestedRules.map((rule) => ` - ${rule}`),
@@ -477,6 +476,24 @@ export function formatQualityScanResult(result: QualityScanResult) {
477
476
  return sections.join("\n");
478
477
  }
479
478
 
479
+ export function formatSsrScanResult(result: QualityScanResult) {
480
+ const sections = [
481
+ "Akan SSR Balance Scan",
482
+ `workspace: ${result.workspaceRoot}`,
483
+ `scanned files: ${result.scannedFiles}`,
484
+ `ssr warnings: ${result.warnings.length}`,
485
+ "",
486
+ "Server render share (component files, JSX elements rendered per side):",
487
+ "",
488
+ ...formatSsrBalance(result.ssrBalance),
489
+ "",
490
+ "Warnings:",
491
+ "",
492
+ ...formatQualityWarnings(result.warnings),
493
+ ];
494
+ return sections.join("\n");
495
+ }
496
+
480
497
  export function formatQualityWarnings(warnings: QualityWarning[]) {
481
498
  if (warnings.length === 0) return ["No warnings found."];
482
499
  return warnings.flatMap((warning) => {
package/scanInfo.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  } from "./akanConfig";
12
12
 
13
13
  import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "./executors";
14
+ import { appRootAllowedDirs, appRootAllowedFiles, libFacetRootAllowedFiles } from "./workspaceLayout";
14
15
 
15
16
  const scalarFileTypes = ["constant", "dictionary", "document", "template", "unit", "util", "view", "zone"] as const;
16
17
  type ScalarFileType = (typeof scalarFileTypes)[number];
@@ -43,49 +44,7 @@ type DatabaseFileType = (typeof databaseFileTypes)[number];
43
44
 
44
45
  type ModuleKind = "database" | "service" | "scalar";
45
46
 
46
- const appRootAllowedFiles = new Set([
47
- // 스코프 에이전트 가이드 — scan(write) 이 유지하는 색인 + 마커 밖 hand-written 내용 (agentsIndex.ts)
48
- "AGENTS.md",
49
- "CLAUDE.md",
50
- "akan.app.json",
51
- "akan.config.ts",
52
- "capacitor.config.ts",
53
- "client.ts",
54
- "main.ts",
55
- "package.json",
56
- "server.ts",
57
- "tsconfig.json",
58
- "tsconfig.tsbuildinfo",
59
- ]);
60
47
  const generatedRootCapacitorConfigFiles = ["capacitor.config.js", "capacitor.config.json"] as const;
61
- const appRootAllowedDirs = new Set([
62
- ".akan",
63
- "android",
64
- "env",
65
- "ios",
66
- "lib",
67
- "mobile",
68
- "page",
69
- "private",
70
- "public",
71
- "script",
72
- "ui",
73
- "srvkit",
74
- "webkit",
75
- "common",
76
- "secrets",
77
- ]);
78
- const libRootAllowedFiles = new Set([
79
- "cnst.ts",
80
- "db.ts",
81
- "dict.ts",
82
- "option.ts",
83
- "sig.ts",
84
- "srv.ts",
85
- "st.ts",
86
- "useClient.ts",
87
- "useServer.ts",
88
- ]);
89
48
  const internalLibDirs = new Set(["__lib", "__scalar"]);
90
49
  const moduleNonUiFileTypes = {
91
50
  database: new Set(["constant", "dictionary", "document", "service", "signal", "store"]),
@@ -107,7 +66,7 @@ const createDependencyScanner = async (exec: AppExecutor | LibExecutor | PkgExec
107
66
 
108
67
  const isAllowedTestFile = (filename: string) => testFilePattern.test(filename);
109
68
  const isAllowedLibRootFile = (filename: string) =>
110
- libRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
69
+ libFacetRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
111
70
  const getScanPath = (exec: AppExecutor | LibExecutor, relativePath: string) =>
112
71
  path.posix.join(`${exec.type}s`, exec.name, relativePath.split(path.sep).join("/"));
113
72
  async function clearGeneratedRootCapacitorConfigs(exec: AppExecutor | LibExecutor) {
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Spinner } from "./spinner";
3
+
4
+ interface TtyStub {
5
+ restore: () => void;
6
+ rawModeCalls: boolean[];
7
+ }
8
+
9
+ /**
10
+ * ora only reaches for stdin when both streams look interactive, so the stub has to fake a tty on
11
+ * stderr (which decides `isEnabled`) as well as on stdin.
12
+ */
13
+ const stubTty = (): TtyStub => {
14
+ const rawModeCalls: boolean[] = [];
15
+ const stdin = process.stdin as NodeJS.ReadStream & { setRawMode?: (mode: boolean) => unknown };
16
+ const previous = {
17
+ stdinIsTTY: stdin.isTTY,
18
+ stderrIsTTY: process.stderr.isTTY,
19
+ stderrColumns: process.stderr.columns,
20
+ setRawMode: stdin.setRawMode,
21
+ isPaused: stdin.isPaused,
22
+ };
23
+ stdin.isTTY = true;
24
+ process.stderr.isTTY = true;
25
+ process.stderr.columns = 120;
26
+ stdin.setRawMode = (mode: boolean) => {
27
+ rawModeCalls.push(mode);
28
+ return stdin;
29
+ };
30
+ stdin.isPaused = () => true;
31
+ return {
32
+ rawModeCalls,
33
+ restore: () => {
34
+ stdin.isTTY = previous.stdinIsTTY;
35
+ process.stderr.isTTY = previous.stderrIsTTY;
36
+ process.stderr.columns = previous.stderrColumns;
37
+ stdin.setRawMode = previous.setRawMode;
38
+ stdin.isPaused = previous.isPaused;
39
+ },
40
+ };
41
+ };
42
+
43
+ describe("Spinner", () => {
44
+ // A raw terminal at spawn time is what a Bun child snapshots and writes back when it exits, which is
45
+ // how `akan start` used to leave a terminal that no longer turns Ctrl+C into SIGINT.
46
+ test("never puts the terminal into raw mode while it spins", () => {
47
+ const tty = stubTty();
48
+ try {
49
+ const spinner = new Spinner("Preparing backend...").start();
50
+ expect(tty.rawModeCalls).toEqual([]);
51
+ spinner.succeed("prepared");
52
+ expect(tty.rawModeCalls).toEqual([]);
53
+ } finally {
54
+ tty.restore();
55
+ }
56
+ });
57
+
58
+ test("keeps ora's stdin discarder disabled", () => {
59
+ expect(Spinner.oraOptions.discardStdin).toBe(false);
60
+ });
61
+
62
+ // An unsized pty reports `isTTY: true` with `columns: 0`, which turns ora's clear loop into an
63
+ // infinite one — the process then writes cursor moves until it is SIGKILLed.
64
+ test("refuses to animate against a tty that reports no width", () => {
65
+ expect(Spinner.canAnimate({ isTTY: true, columns: 0 } as NodeJS.WriteStream)).toBe(false);
66
+ expect(Spinner.canAnimate({ isTTY: true, columns: 120 } as NodeJS.WriteStream)).toBe(true);
67
+ expect(Spinner.canAnimate({ isTTY: false, columns: 0 } as NodeJS.WriteStream)).toBe(true);
68
+ });
69
+
70
+ test("falls back to plain lines when the terminal has no width", () => {
71
+ const previous = { isTTY: process.stderr.isTTY, columns: process.stderr.columns };
72
+ process.stderr.isTTY = true;
73
+ process.stderr.columns = 0;
74
+ try {
75
+ expect(new Spinner("Preparing backend...").enableSpin).toBe(false);
76
+ } finally {
77
+ process.stderr.isTTY = previous.isTTY;
78
+ process.stderr.columns = previous.columns;
79
+ }
80
+ });
81
+ });
package/spinner.ts CHANGED
@@ -2,6 +2,26 @@ import ora, { type Ora } from "ora";
2
2
 
3
3
  export class Spinner {
4
4
  static padding = 12;
5
+ /**
6
+ * XXX: `discardStdin` must stay off. It makes ora put the terminal into raw mode for as long as the
7
+ * spinner runs, and a Bun child spawned in that window snapshots the raw termios and writes it back
8
+ * when it exits — long after the spinner restored the terminal. `akan start` spawns the builder and
9
+ * the backend under the "Preparing backend..." spinner, so the first builder recycle (a config or
10
+ * runtime-metadata change) SIGTERMs that child and silently turns the developer's terminal raw:
11
+ * `isig` goes off, Ctrl+C stops producing SIGINT at all, and the dev server looks unkillable.
12
+ */
13
+ static oraOptions = { discardStdin: false } as const;
14
+ /**
15
+ * ora sizes its clear loop as `ceil(lineWidth / stream.columns)`, so a tty that reports **0** columns
16
+ * makes it `Infinity` and `clear()` never returns. An unsized pty does exactly that (`isTTY: true`,
17
+ * `columns: 0`) — CI runners, `expect`/`script` harnesses, some detached panes. Measured: 750MB of
18
+ * cursor moves and 8.7GB RSS inside a minute, in a loop that no longer reaches the point where SIGINT
19
+ * or SIGTERM could be handled, so only SIGKILL ends it. A terminal with no width has nothing to
20
+ * animate anyway; fall back to plain lines.
21
+ */
22
+ static canAnimate(stream: NodeJS.WriteStream = process.stderr): boolean {
23
+ return !stream.isTTY || stream.columns > 0;
24
+ }
5
25
  spinner: Ora;
6
26
  stopWatch: NodeJS.Timeout | null = null;
7
27
  startAt: Date = new Date();
@@ -12,10 +32,10 @@ export class Spinner {
12
32
  Spinner.padding = Math.max(Spinner.padding, prefix.length);
13
33
  this.prefix = prefix;
14
34
  this.message = message;
15
- this.spinner = ora(message);
35
+ this.spinner = ora({ ...Spinner.oraOptions, text: message });
16
36
  this.spinner.prefixText = prefix.padStart(Spinner.padding, " ");
17
37
  this.spinner.indent = indent;
18
- this.enableSpin = enableSpin;
38
+ this.enableSpin = enableSpin && Spinner.canAnimate();
19
39
  }
20
40
  start() {
21
41
  this.startAt = new Date();