authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 17:35:02-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 17:35:02-07:00
log68c0dd3b7095cae054a9db5e48157e89f65c0664
treee43a874108cf31161e43ac53e106e14962199869
parent4cd958445f60dcdbc5ad3997a35b1bd995e9d725
signature Signed by PGP key B5690EEEBB952194

[codex] Structure server CLI failures (#3450)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 120 insertions(+), 24 deletions(-)

apps/server/scripts/cli.ts+19-24
......@@ -1,7 +1,6 @@
11#!/usr/bin/env node
22import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
33import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Data from "effect/Data";
54import * as Effect from "effect/Effect";
65import * as FileSystem from "effect/FileSystem";
76import * as Logger from "effect/Logger";
......@@ -20,6 +19,14 @@ import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
2019import { fromYaml } from "@t3tools/shared/schemaYaml";
2120import { resolveSpawnCommand } from "@t3tools/shared/shell";
2221import serverPackageJson from "../package.json" with { type: "json" };
22import {
23 ServerCliBuildAssetMissingError,
24 ServerCliCommandExitError,
25 ServerCliDevelopmentIconSourceMissingError,
26 ServerCliDevelopmentIconTargetMissingError,
27 ServerCliPublishIconSourceMissingError,
28 ServerCliPublishIconTargetMissingError,
29} from "./cliErrors.ts";
2330
2431interface PackageJson {
2532 name: string;
......@@ -47,11 +54,6 @@ const WorkspaceConfig = Schema.Struct({
4754type WorkspaceConfig = typeof WorkspaceConfig.Type;
4855const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));
4956
50class CliError extends Data.TaggedError("CliError")<{
51 readonly message: string;
52 readonly cause?: unknown;
53}> {}
54
5557const RepoRoot = Effect.service(Path.Path).pipe(
5658 Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))),
5759);
......@@ -64,14 +66,17 @@ const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () {
6466 return yield* decodeWorkspaceConfig(workspaceYaml);
6567});
6668
67const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Command) {
69const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) {
6870 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
6971 const child = yield* spawner.spawn(command);
7072 const exitCode = yield* child.exitCode;
7173
7274 if (exitCode !== 0) {
73 return yield* new CliError({
74 message: `Command exited with non-zero exit code (${exitCode})`,
75 return yield* new ServerCliCommandExitError({
76 command: command.command,
77 args: command.args,
78 cwd: command.options.cwd,
79 exitCode,
7580 });
7681 }
7782});
......@@ -95,14 +100,10 @@ const applyPublishIconOverrides = Effect.fn("applyPublishIconOverrides")(functio
95100 const backupPath = `${targetPath}.publish-bak`;
96101
97102 if (!(yield* fs.exists(sourcePath))) {
98 return yield* new CliError({
99 message: `Missing publish icon source: ${sourcePath}`,
100 });
103 return yield* new ServerCliPublishIconSourceMissingError({ sourcePath });
101104 }
102105 if (!(yield* fs.exists(targetPath))) {
103 return yield* new CliError({
104 message: `Missing publish icon target: ${targetPath}. Run the build subcommand first.`,
105 });
106 return yield* new ServerCliPublishIconTargetMissingError({ targetPath });
106107 }
107108
108109 yield* fs.copyFile(targetPath, backupPath);
......@@ -138,14 +139,10 @@ const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")
138139 const targetPath = path.join(serverDir, override.targetRelativePath);
139140
140141 if (!(yield* fs.exists(sourcePath))) {
141 return yield* new CliError({
142 message: `Missing development icon source: ${sourcePath}`,
143 });
142 return yield* new ServerCliDevelopmentIconSourceMissingError({ sourcePath });
144143 }
145144 if (!(yield* fs.exists(targetPath))) {
146 return yield* new CliError({
147 message: `Missing development icon target: ${targetPath}. Build web first.`,
148 });
145 return yield* new ServerCliDevelopmentIconTargetMissingError({ targetPath });
149146 }
150147
151148 yield* fs.copyFile(sourcePath, targetPath);
......@@ -245,9 +242,7 @@ const publishCmd = Command.make(
245242 for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) {
246243 const abs = path.join(serverDir, relPath);
247244 if (!(yield* fs.exists(abs))) {
248 return yield* new CliError({
249 message: `Missing build asset: ${abs}. Run the build subcommand first.`,
250 });
245 return yield* new ServerCliBuildAssetMissingError({ assetPath: abs });
251246 }
252247 }
253248
apps/server/scripts/cliErrors.test.ts created+31
......@@ -0,0 +1,31 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { ServerCliBuildAssetMissingError, ServerCliCommandExitError } from "./cliErrors.ts";
4
5describe("server CLI errors", () => {
6 it("preserves failed command context without changing its message", () => {
7 const error = new ServerCliCommandExitError({
8 command: "vp",
9 args: ["pm", "publish"],
10 cwd: "/repo",
11 exitCode: 17,
12 });
13
14 assert.equal(error._tag, "ServerCliCommandExitError");
15 assert.equal(error.command, "vp");
16 assert.deepEqual(error.args, ["pm", "publish"]);
17 assert.equal(error.cwd, "/repo");
18 assert.equal(error.exitCode, 17);
19 assert.equal(error.message, "Command exited with non-zero exit code (17)");
20 });
21
22 it("preserves a representative missing asset path", () => {
23 const error = new ServerCliBuildAssetMissingError({ assetPath: "/repo/server.mjs" });
24
25 assert.equal(error.assetPath, "/repo/server.mjs");
26 assert.equal(
27 error.message,
28 "Missing build asset: /repo/server.mjs. Run the build subcommand first.",
29 );
30 });
31});
apps/server/scripts/cliErrors.ts created+70
......@@ -0,0 +1,70 @@
1import * as Schema from "effect/Schema";
2
3export class ServerCliCommandExitError extends Schema.TaggedErrorClass<ServerCliCommandExitError>()(
4 "ServerCliCommandExitError",
5 {
6 command: Schema.String,
7 args: Schema.Array(Schema.String),
8 cwd: Schema.optional(Schema.String),
9 exitCode: Schema.Int,
10 },
11) {
12 override get message(): string {
13 return `Command exited with non-zero exit code (${this.exitCode})`;
14 }
15}
16
17export class ServerCliPublishIconSourceMissingError extends Schema.TaggedErrorClass<ServerCliPublishIconSourceMissingError>()(
18 "ServerCliPublishIconSourceMissingError",
19 {
20 sourcePath: Schema.String,
21 },
22) {
23 override get message(): string {
24 return `Missing publish icon source: ${this.sourcePath}`;
25 }
26}
27
28export class ServerCliPublishIconTargetMissingError extends Schema.TaggedErrorClass<ServerCliPublishIconTargetMissingError>()(
29 "ServerCliPublishIconTargetMissingError",
30 {
31 targetPath: Schema.String,
32 },
33) {
34 override get message(): string {
35 return `Missing publish icon target: ${this.targetPath}. Run the build subcommand first.`;
36 }
37}
38
39export class ServerCliDevelopmentIconSourceMissingError extends Schema.TaggedErrorClass<ServerCliDevelopmentIconSourceMissingError>()(
40 "ServerCliDevelopmentIconSourceMissingError",
41 {
42 sourcePath: Schema.String,
43 },
44) {
45 override get message(): string {
46 return `Missing development icon source: ${this.sourcePath}`;
47 }
48}
49
50export class ServerCliDevelopmentIconTargetMissingError extends Schema.TaggedErrorClass<ServerCliDevelopmentIconTargetMissingError>()(
51 "ServerCliDevelopmentIconTargetMissingError",
52 {
53 targetPath: Schema.String,
54 },
55) {
56 override get message(): string {
57 return `Missing development icon target: ${this.targetPath}. Build web first.`;
58 }
59}
60
61export class ServerCliBuildAssetMissingError extends Schema.TaggedErrorClass<ServerCliBuildAssetMissingError>()(
62 "ServerCliBuildAssetMissingError",
63 {
64 assetPath: Schema.String,
65 },
66) {
67 override get message(): string {
68 return `Missing build asset: ${this.assetPath}. Run the build subcommand first.`;
69 }
70}