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 @@...@@ -1,7 +1,6 @@
1#!/usr/bin/env node1#!/usr/bin/env node
2import * as NodeRuntime from "@effect/platform-node/NodeRuntime";2import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
7import * as Logger from "effect/Logger";6import * as Logger from "effect/Logger";
...@@ -20,6 +19,14 @@ import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";...@@ -20,6 +19,14 @@ import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
20import { fromYaml } from "@t3tools/shared/schemaYaml";19import { fromYaml } from "@t3tools/shared/schemaYaml";
21import { resolveSpawnCommand } from "@t3tools/shared/shell";20import { resolveSpawnCommand } from "@t3tools/shared/shell";
22import serverPackageJson from "../package.json" with { type: "json" };21import 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
24interface PackageJson {31interface PackageJson {
25 name: string;32 name: string;
...@@ -47,11 +54,6 @@ const WorkspaceConfig = Schema.Struct({...@@ -47,11 +54,6 @@ const WorkspaceConfig = Schema.Struct({
47type WorkspaceConfig = typeof WorkspaceConfig.Type;54type WorkspaceConfig = typeof WorkspaceConfig.Type;
48const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));55const decodeWorkspaceConfig = Schema.decodeEffect(fromYaml(WorkspaceConfig));
4956
50class CliError extends Data.TaggedError("CliError")<{
51 readonly message: string;
52 readonly cause?: unknown;
53}> {}
54
55const RepoRoot = Effect.service(Path.Path).pipe(57const RepoRoot = Effect.service(Path.Path).pipe(
56 Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))),58 Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))),
57);59);
...@@ -64,14 +66,17 @@ const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () {...@@ -64,14 +66,17 @@ const readWorkspaceConfig = Effect.fn("readWorkspaceConfig")(function* () {
64 return yield* decodeWorkspaceConfig(workspaceYaml);66 return yield* decodeWorkspaceConfig(workspaceYaml);
65});67});
6668
67const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Command) {69const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.StandardCommand) {
68 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;70 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
69 const child = yield* spawner.spawn(command);71 const child = yield* spawner.spawn(command);
70 const exitCode = yield* child.exitCode;72 const exitCode = yield* child.exitCode;
7173
72 if (exitCode !== 0) {74 if (exitCode !== 0) {
73 return yield* new CliError({75 return yield* new ServerCliCommandExitError({
74 message: `Command exited with non-zero exit code (${exitCode})`,76 command: command.command,
77 args: command.args,
78 cwd: command.options.cwd,
79 exitCode,
75 });80 });
76 }81 }
77});82});
...@@ -95,14 +100,10 @@ const applyPublishIconOverrides = Effect.fn("applyPublishIconOverrides")(functio...@@ -95,14 +100,10 @@ const applyPublishIconOverrides = Effect.fn("applyPublishIconOverrides")(functio
95 const backupPath = `${targetPath}.publish-bak`;100 const backupPath = `${targetPath}.publish-bak`;
96101
97 if (!(yield* fs.exists(sourcePath))) {102 if (!(yield* fs.exists(sourcePath))) {
98 return yield* new CliError({103 return yield* new ServerCliPublishIconSourceMissingError({ sourcePath });
99 message: `Missing publish icon source: ${sourcePath}`,
100 });
101 }104 }
102 if (!(yield* fs.exists(targetPath))) {105 if (!(yield* fs.exists(targetPath))) {
103 return yield* new CliError({106 return yield* new ServerCliPublishIconTargetMissingError({ targetPath });
104 message: `Missing publish icon target: ${targetPath}. Run the build subcommand first.`,
105 });
106 }107 }
107108
108 yield* fs.copyFile(targetPath, backupPath);109 yield* fs.copyFile(targetPath, backupPath);
...@@ -138,14 +139,10 @@ const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")...@@ -138,14 +139,10 @@ const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")
138 const targetPath = path.join(serverDir, override.targetRelativePath);139 const targetPath = path.join(serverDir, override.targetRelativePath);
139140
140 if (!(yield* fs.exists(sourcePath))) {141 if (!(yield* fs.exists(sourcePath))) {
141 return yield* new CliError({142 return yield* new ServerCliDevelopmentIconSourceMissingError({ sourcePath });
142 message: `Missing development icon source: ${sourcePath}`,
143 });
144 }143 }
145 if (!(yield* fs.exists(targetPath))) {144 if (!(yield* fs.exists(targetPath))) {
146 return yield* new CliError({145 return yield* new ServerCliDevelopmentIconTargetMissingError({ targetPath });
147 message: `Missing development icon target: ${targetPath}. Build web first.`,
148 });
149 }146 }
150147
151 yield* fs.copyFile(sourcePath, targetPath);148 yield* fs.copyFile(sourcePath, targetPath);
...@@ -245,9 +242,7 @@ const publishCmd = Command.make(...@@ -245,9 +242,7 @@ const publishCmd = Command.make(
245 for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) {242 for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) {
246 const abs = path.join(serverDir, relPath);243 const abs = path.join(serverDir, relPath);
247 if (!(yield* fs.exists(abs))) {244 if (!(yield* fs.exists(abs))) {
248 return yield* new CliError({245 return yield* new ServerCliBuildAssetMissingError({ assetPath: abs });
249 message: `Missing build asset: ${abs}. Run the build subcommand first.`,
250 });
251 }246 }
252 }247 }
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}