Skip to content

Commit 1b0eff4

Browse files
committed
feat(cli): better error handling and logs (#868)
1 parent 17375c6 commit 1b0eff4

File tree

6 files changed

+178
-77
lines changed

6 files changed

+178
-77
lines changed

packages/actor-core-cli/src/commands/dev.tsx

Lines changed: 89 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import { spawn } from "node:child_process";
1212
export const dev = new Command()
1313
.name("dev")
1414
.description("Run locally your ActorCore project.")
15-
.addArgument(new Argument("<path>", "Location of the app.ts file"))
15+
.addArgument(
16+
new Argument("[path]", "Location of the app.ts file").default(
17+
"actors/app.ts",
18+
),
19+
)
1620
.addOption(
1721
new Option("-r, --root [path]", "Location of the project").default("./"),
1822
)
@@ -29,77 +33,102 @@ export const dev = new Command()
2933
.option("--no-open", "Do not open the browser with ActorCore Studio")
3034
.action(action);
3135

32-
export async function action(appPath: string, opts: {
33-
root: string;
34-
port?: string;
35-
open: boolean;
36-
}) {
36+
export async function action(
37+
appPath: string,
38+
opts: {
39+
root: string;
40+
port?: string;
41+
open: boolean;
42+
},
43+
) {
3744
const cwd = path.join(process.cwd(), opts.root);
3845

39-
await workflow("Run locally your ActorCore project", async function*(ctx) {
40-
if (opts.open) {
41-
open(
42-
process.env._ACTOR_CORE_CLI_DEV
43-
? "http://localhost:43708"
44-
: "http://studio.rivet.gg",
45-
);
46-
}
46+
await workflow(
47+
`Run locally your ActorCore project (${appPath})`,
48+
async function* (ctx) {
49+
if (opts.open) {
50+
open(
51+
process.env._ACTOR_CORE_CLI_DEV
52+
? "http://localhost:43708"
53+
: "http://studio.rivet.gg",
54+
);
55+
}
4756

48-
const watcher = chokidar.watch(cwd, {
49-
awaitWriteFinish: true,
50-
ignoreInitial: true,
51-
ignored: (path) => path.includes("node_modules"),
52-
});
57+
const watcher = chokidar.watch(cwd, {
58+
awaitWriteFinish: true,
59+
ignoreInitial: true,
60+
ignored: (path) => path.includes("node_modules"),
61+
});
5362

54-
function createServer() {
55-
return spawn(
56-
process.execPath,
57-
[
58-
path.join(
59-
path.dirname(require.resolve("@actor-core/cli")),
60-
"server-entry.js",
61-
),
62-
],
63-
{ env: { ...process.env, PORT: opts.port, APP_PATH: appPath }, cwd },
64-
);
65-
}
63+
function createServer() {
64+
return spawn(
65+
process.execPath,
66+
[
67+
path.join(
68+
path.dirname(require.resolve("@actor-core/cli")),
69+
"server-entry.js",
70+
),
71+
],
72+
{
73+
env: { ...process.env, PORT: opts.port, APP_PATH: appPath },
74+
cwd,
75+
stdio: "overlapped",
76+
},
77+
);
78+
}
6679

67-
let server: ReturnType<typeof spawn> | undefined = undefined;
68-
let lock: ReturnType<typeof withResolvers> = withResolvers();
80+
let server: ReturnType<typeof spawn> | undefined = undefined;
81+
let lock: ReturnType<typeof withResolvers> = withResolvers();
6982

70-
function createLock() {
71-
if (lock) {
72-
lock.resolve(undefined);
83+
function createLock() {
84+
if (lock) {
85+
lock.resolve(undefined);
86+
}
87+
lock = withResolvers();
7388
}
74-
lock = withResolvers();
75-
}
7689

77-
watcher.on("all", async (_, path) => {
78-
if (path.includes("node_modules") || path.includes("/.")) return;
90+
watcher.on("all", async (_, path) => {
91+
if (path.includes("node_modules") || path.includes("/.")) return;
7992

80-
server?.kill();
81-
});
93+
if (server?.exitCode === 1) {
94+
// Server exited with error
95+
console.log("Server exited with error");
96+
lock.resolve(undefined);
97+
return;
98+
} else {
99+
server?.kill("SIGQUIT");
100+
}
101+
});
82102

83-
while (true) {
84-
yield* validateConfigTask(ctx, cwd, appPath);
85-
server = createServer();
86103
createLock();
87104

88-
server?.addListener("exit", () => {
89-
lock.resolve(undefined);
90-
});
105+
while (true) {
106+
yield* validateConfigTask(ctx, cwd, appPath);
107+
yield* ctx.task(
108+
"Server started. Watching for changes",
109+
async function* (ctx) {
110+
server = createServer();
111+
if (server?.stdout) {
112+
yield ctx.attach(server.stdout, server.stderr);
113+
}
114+
createLock();
91115

92-
server?.addListener("close", () => {
93-
lock.resolve(undefined);
94-
});
116+
server?.addListener("exit", (code) => {
117+
if (code === 1) {
118+
ctx.changeLabel(
119+
"Server exited with error. It will be restarted after next file change...",
120+
);
121+
// Server exited with error
122+
return;
123+
}
124+
lock.resolve(undefined);
125+
});
95126

96-
yield* ctx.task(
97-
"Watching for changes...",
98-
async () => {
99-
await lock.promise;
100-
},
101-
{ success: <Text dimColor> (Changes detected, restarting!)</Text> },
102-
);
103-
}
104-
}).render();
127+
await lock.promise;
128+
},
129+
{ success: <Text dimColor> (Changes detected, restarting!)</Text> },
130+
);
131+
}
132+
},
133+
).render();
105134
}

packages/actor-core-cli/src/ui/Workflow.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import {
99
import { ExecaError } from "execa";
1010
import { Box, Text, type TextProps } from "ink";
1111
import Spinner from "ink-spinner";
12-
import { type ReactNode, useState } from "react";
12+
import { type ReactNode, useEffect, useState } from "react";
1313
import stripAnsi from "strip-ansi";
1414
import { type WorkflowAction, WorkflowError } from "../workflow";
15+
import type Stream from "node:stream";
1516

1617
const customTheme = extendTheme(defaultTheme, {
1718
components: {
@@ -204,11 +205,50 @@ export function Task({
204205
)}
205206
</Box>
206207
) : null}
208+
{task.streams ? <StreamBox streams={task.streams} /> : null}
207209
</>
208210
);
209211
}
210212
}
211213

214+
export function StreamBox({ streams }: { streams: Stream.Readable[] }) {
215+
const [chunks, setChunks] = useState<string[]>([]);
216+
217+
useEffect(() => {
218+
const handleChunk = (chunk: Buffer) => {
219+
setChunks((old) => {
220+
const lines = chunk
221+
.toString()
222+
.split(/\n/gm)
223+
.flatMap((line) => line.split(/\\n/g))
224+
.map((line) => stripAnsi(line));
225+
return [...old, ...lines];
226+
});
227+
};
228+
for (const stream of streams) {
229+
stream.on("data", handleChunk);
230+
}
231+
232+
return () => {
233+
for (const stream of streams) {
234+
stream.off("data", handleChunk);
235+
}
236+
};
237+
}, [streams]);
238+
239+
return (
240+
<Box flexDirection="column" marginLeft={2} overflow="visible">
241+
{chunks.map((chunk, chunkIdx) => {
242+
return (
243+
<Text dimColor key={`${chunkIdx}`}>
244+
{chunk.replaceAll("\n", "")}
245+
</Text>
246+
);
247+
})}
248+
</Box>
249+
);
250+
}
251+
212252
export function Status({
213253
value,
214254
children,

packages/actor-core-cli/src/utils/config.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import path from "node:path";
22
import { bundleRequire } from "bundle-require";
33
import z from "zod";
4-
import type { ActorCoreApp } from "actor-core";
4+
import { type ActorCoreApp, AppConfigSchema } from "actor-core";
55

66
const ActorCoreConfig = z.object({
7-
// biome-ignore lint/suspicious/noExplicitAny: we need to use any here because we don't know the type of the app
8-
app: z.custom<ActorCoreApp<any>>(),
7+
app: z.custom<ActorCoreApp<any>>((val) => {
8+
return AppConfigSchema.parse(val.config);
9+
}),
910
cwd: z.string(),
1011
});
1112

packages/actor-core-cli/src/workflow.tsx

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Logs, WorkflowDetails } from "./ui/Workflow";
55
import { withResolvers } from "./utils/mod";
66
import type React from "react";
77
import type { ReactNode } from "react";
8+
import type Stream from "node:stream";
89

910
interface WorkflowResult {
1011
success: boolean;
@@ -29,13 +30,18 @@ export namespace WorkflowAction {
2930
meta: TaskMetadata;
3031
result?: unknown;
3132
error?: unknown;
33+
streams?: Stream.Readable[];
3234
__taskProgress: true;
3335
}
3436

3537
export const progress = (
3638
meta: TaskMetadata,
3739
status: "running" | "done" | "error",
38-
res: { error?: unknown; result?: unknown } & TaskOptions = {},
40+
res: {
41+
error?: unknown;
42+
result?: unknown;
43+
streams?: Stream.Readable[];
44+
} & TaskOptions = {},
3945
): Progress => ({
4046
status,
4147
meta,
@@ -179,6 +185,8 @@ export interface Context {
179185
? G
180186
: T
181187
>;
188+
attach: (...streams: (Stream.Readable | null)[]) => WorkflowAction.All;
189+
changeLabel: (label: string) => void;
182190
render: (children: React.ReactNode) => WorkflowAction.All;
183191
error: (error: string, opts?: WorkflowErrorOpts) => WorkflowError;
184192
warn: (message: ReactNode) => Generator<WorkflowAction.Log>;
@@ -251,7 +259,9 @@ export function workflow(
251259
}
252260
}
253261

254-
function createContext(meta: TaskMetadata): Context {
262+
function createContext(
263+
meta: TaskMetadata & { processTask: (task: WorkflowAction.All) => void },
264+
): Context {
255265
return {
256266
wait: (ms: number) =>
257267
new Promise<undefined>((resolve) => setTimeout(resolve, ms)),
@@ -272,6 +282,16 @@ export function workflow(
272282
);
273283
});
274284
},
285+
attach(...streams: (Stream.Readable | null)[]) {
286+
return WorkflowAction.progress(meta, "running", {
287+
streams: streams.filter((s) => s !== null) as Stream.Readable[],
288+
});
289+
},
290+
changeLabel: (label: string) => {
291+
meta.processTask(
292+
WorkflowAction.progress({ ...meta, name: label }, "running"),
293+
);
294+
},
275295
error(error, opts) {
276296
return new WorkflowError(error, opts);
277297
},
@@ -329,10 +349,9 @@ export function workflow(
329349
};
330350
}
331351

332-
async function* workflowRunner(): AsyncGenerator<
333-
WorkflowAction.All,
334-
WorkflowResult
335-
> {
352+
async function* workflowRunner(
353+
processTask: (task: WorkflowAction.All) => void,
354+
): AsyncGenerator<WorkflowAction.All, WorkflowResult> {
336355
// task <> parent
337356
const parentMap = new Map<string, string>();
338357
const id = getTaskId();
@@ -342,7 +361,7 @@ export function workflow(
342361
"running",
343362
);
344363
for await (const task of workflowFn(
345-
createContext({ id, name: title, parent: id }),
364+
createContext({ id, name: title, parent: id, processTask }),
346365
)) {
347366
if (!task || typeof task !== "object") {
348367
continue;
@@ -412,14 +431,14 @@ export function workflow(
412431
const logs: WorkflowAction.Log[] = [];
413432
const tasks: WorkflowAction.Interface[] = [];
414433

415-
for await (const task of workflowRunner()) {
434+
function processTask(task: WorkflowAction.All) {
416435
if ("__taskLog" in task) {
417436
logs.push(task);
418-
continue;
437+
return;
419438
}
420439
if ("__taskHook" in task) {
421440
hooks[task.hook].push(task.fn);
422-
continue;
441+
return;
423442
}
424443

425444
const index = tasks.findIndex((t) => t.meta.id === task.meta.id);
@@ -429,7 +448,7 @@ export function workflow(
429448
tasks[index] = { ...tasks[index], ...task };
430449
}
431450

432-
renderUtils.rerender(
451+
renderUtils?.rerender(
433452
<Box flexDirection="column">
434453
<Intro />
435454
<WorkflowDetails tasks={tasks} interactive={interactive} />
@@ -438,6 +457,10 @@ export function workflow(
438457
);
439458
}
440459

460+
for await (const task of workflowRunner(processTask)) {
461+
processTask(task);
462+
}
463+
441464
for (const hook of hooks.afterAll) {
442465
hook({ tasks, logs });
443466
}

packages/actor-core/src/actor/mod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {
2-
ActorConfigInput,
2+
type ActorConfigInput,
33
ActorConfigSchema,
44
type Actions,
55
type ActorConfig,

0 commit comments

Comments
 (0)