diff --git a/README.ja.md b/README.ja.md index 2695718..5f5c687 100644 --- a/README.ja.md +++ b/README.ja.md @@ -314,6 +314,22 @@ JavaScript 設定、最終 MPK ビルドを担当します。 | `gleam run -m glendix/lint_fix` | lint 修正 | | `gleam run -m glendix/release` | release ビルド | +### コマンド実行境界 + +`glendix/cmd.exec` は stdin、stdout、stderr を継承する同期 shell command +API を維持します。一般的な process 実行は `shellout` で実装し、小さな +platform adapter が Unix では `/bin/sh -c`、Windows では +`ComSpec /d /s /c` を選択することで、既存の command string と shell +operator の挙動を保ちます。`plinth/node/child_process` は、現在の API が同期 +完了、exit status、標準 stream option を typed result として提供しないため、 +この境界には使用しません。 + +`cmd_ffi.mjs` に残る custom command tooling は、意図的に Glendix 固有です。 +bridge の生成と cleanup、開発 watcher、experimental-native runtime 設定、 +Node/npm 互換 shim、Rollup/WebAssembly 処理が含まれます。Gleam build +command も限定的な filtered runner を維持します。`shellout` では既存の stream +継承を保ちながら stderr だけを capture して filter できないためです。 + ## 6.0.0 の破壊的変更 `glendix/js/array` は変換を `gleam/javascript/array` に委譲するようになり、手書き diff --git a/README.ko.md b/README.ko.md index 8f61181..2481101 100644 --- a/README.ko.md +++ b/README.ko.md @@ -306,6 +306,21 @@ JavaScript 설정, 최종 MPK 빌드를 담당한다. Marketplace 위젯을 쓰 | `gleam run -m glendix/lint_fix` | lint 수정 | | `gleam run -m glendix/release` | release 빌드 | +### 명령 실행 경계 + +`glendix/cmd.exec`는 stdin, stdout, stderr를 상속하는 동기식 shell command +API를 유지한다. 일반 process 실행은 `shellout`으로 구현하고, 작은 platform +adapter가 Unix에서는 `/bin/sh -c`, Windows에서는 `ComSpec /d /s /c`를 선택해 +기존 command string과 shell operator의 동작을 보존한다. +`plinth/node/child_process`는 현재 API가 동기 완료, exit status, 표준 stream +option을 typed result로 제공하지 않으므로 이 경계에는 사용하지 않는다. + +`cmd_ffi.mjs`에 남은 custom command tooling은 의도적으로 Glendix에 특화되어 +있다. 여기에는 bridge 생성과 정리, 개발 watcher, experimental-native runtime +설정, Node/npm 호환 shim, Rollup/WebAssembly 처리가 포함된다. Gleam build +command도 좁은 filtered runner를 유지한다. `shellout`은 기존의 stream 상속 +동작을 보존하면서 stderr만 별도로 capture하고 filter할 수 없기 때문이다. + ## 6.0.0 호환성 변경 `glendix/js/array`는 이제 변환을 `gleam/javascript/array`에 위임하며 수기 diff --git a/README.md b/README.md index de6a278..3a088ba 100644 --- a/README.md +++ b/README.md @@ -403,6 +403,23 @@ Projects that do not use Marketplace widgets omit the first two steps. | `gleam run -m glendix/lint_fix` | Apply lint fixes | | `gleam run -m glendix/release` | Run the release build | +### Command execution boundary + +`glendix/cmd.exec` remains a synchronous shell-command API with inherited +stdin, stdout, and stderr. Its generic process execution is implemented with +`shellout`; a small platform adapter selects `/bin/sh -c` on Unix systems and +`ComSpec /d /s /c` on Windows so existing command strings and shell operators +keep their behavior. `plinth/node/child_process` is not used here because its +current API does not provide synchronous completion, exit status, and standard +stream options as a typed result. + +The custom command tooling retained in `cmd_ffi.mjs` is intentionally +Glendix-specific: bridge generation and cleanup, the development watcher, +experimental-native runtime setup, Node/npm compatibility shims, and +Rollup/WebAssembly handling. Gleam build commands also retain a narrow filtered +runner because `shellout` cannot separately capture and filter stderr while +preserving the existing inherited-stream behavior. + ## Breaking changes in 6.0.0 `glendix/js/array` now delegates its conversions to `gleam/javascript/array` diff --git a/gleam.toml b/gleam.toml index f694afa..294ba30 100644 --- a/gleam.toml +++ b/gleam.toml @@ -25,6 +25,7 @@ term_size = ">= 1.0.1 and < 2.0.0" tom = ">= 2.1.0 and < 3.0.0" gossamer = ">= 10.0.0 and < 11.0.0" xmlm = ">= 1.0.1 and < 2.0.0" +shellout = ">= 1.8.0 and < 2.0.0" [dev_dependencies] gleeunit = ">= 1.11.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 451d876..fddbe2a 100644 --- a/manifest.toml +++ b/manifest.toml @@ -28,6 +28,7 @@ packages = [ { name = "plinth", version = "0.11.0", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "D30FDE8FC11EB76538AB498ACFF5399D0AE27993C74D63BE05AFE8CE80C4C839" }, { name = "redraw", version = "19.2.2", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], otp_app = "redraw", source = "hex", outer_checksum = "B8CEEB74E8846CE10B8360B924DAD22441B61D947F9449854164F0686C4B8661" }, { name = "redraw_dom", version = "19.2.2", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_stdlib", "redraw"], otp_app = "redraw_dom", source = "hex", outer_checksum = "80278296AD6E3D4457D6FF6A14FEA3E90696284BEA297BDE10E430FC4CE726B8" }, + { name = "shellout", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "C416356D45151F298108C9DB9CD1EDE0313F620B5EDBB5766CD7237659D87841" }, { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" }, { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, { name = "tom", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "DCF04CB7AB35D58CFC598C66EA2E1816D160759802C89B2BA6238780D59BC256" }, @@ -46,6 +47,7 @@ mendraw = { version = ">= 2.0.0 and < 3.0.0" } plinth = { version = ">= 0.11.0 and < 1.0.0" } redraw = { version = ">= 19.2.2 and < 20.0.0" } redraw_dom = { version = ">= 19.2.2 and < 20.0.0" } +shellout = { version = ">= 1.8.0 and < 2.0.0" } simplifile = { version = ">= 2.6.0 and < 3.0.0" } term_size = { version = ">= 1.0.1 and < 2.0.0" } tom = { version = ">= 2.1.0 and < 3.0.0" } diff --git a/src/glendix/cmd.gleam b/src/glendix/cmd.gleam index a9ae71d..8e64c34 100644 --- a/src/glendix/cmd.gleam +++ b/src/glendix/cmd.gleam @@ -5,6 +5,7 @@ import gleam/io import gleam/list import gleam/option import gleam/result +import glendix/command import glendix/configuration /// Describes a JavaScript tooling failure. @@ -15,8 +16,13 @@ pub type CommandError { /// Executes a command through the JavaScript process boundary. pub fn exec(command command: String) -> Result(Nil, CommandError) { - exec_raw(command) - |> map_raw_error("execute command") + command.run(command) + |> result.map_error(fn(error) { + CommandFailed( + operation: "execute command", + reason: command.error_message(error), + ) + }) } /// Detects the configured or lockfile-selected JavaScript package manager. @@ -331,9 +337,6 @@ fn map_configuration_error( } // -- FFI -- -@external(javascript, "./cmd_ffi.mjs", "exec") -fn exec_raw(command command: String) -> Result(Nil, RawCommandError) - @external(javascript, "./cmd_ffi.mjs", "file_exists") fn file_exists(path: String) -> Bool diff --git a/src/glendix/cmd_ffi.mjs b/src/glendix/cmd_ffi.mjs index 42d85eb..96e3968 100644 --- a/src/glendix/cmd_ffi.mjs +++ b/src/glendix/cmd_ffi.mjs @@ -1,4 +1,4 @@ -import { execSync, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { chmodSync, @@ -17,6 +17,7 @@ import { tmpdir } from "node:os"; import { basename, delimiter, dirname, extname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { Ok, Error as GleamError } from "../gleam.mjs"; +import { runFilteredCommandOrThrow } from "./command_ffi.mjs"; const EXPERIMENTAL_NATIVE_RUNNER = "--glendix-experimental-native-runner"; const EXPERIMENTAL_NATIVE_SHIM = "--glendix-experimental-native-shim"; @@ -30,62 +31,6 @@ function errorMessage(error) { export function command_error_message(error) { return errorMessage(error); } -function filterErlangWarnings(stderr) { - const lines = stderr.split(/\r?\n/); - const result = []; - let skip = false; - let skipNextEmpty = false; - for (let i = 0; i < lines.length; i++) { - if (!skip && lines[i] === "warning: Unused value") { - if (i + 1 < lines.length && lines[i + 1].includes("gleam_erlang")) { - skip = true; - continue; - } - } - if (skip) { - if (lines[i].includes("not needed")) { - skip = false; - skipNextEmpty = true; - } - continue; - } - if (skipNextEmpty) { - skipNextEmpty = false; - if (lines[i].trim() === "") continue; - } - result.push(lines[i]); - } - return result.join("\n").replace(/\n{3,}/g, "\n\n").trim(); -} -function execGleamFiltered(command) { - const result = spawnSync(command, { shell: true, stdio: ["inherit", "pipe", "pipe"] }); - if (result.stdout && result.stdout.length > 0) process.stdout.write(result.stdout); - if (result.stderr && result.stderr.length > 0) { - const filtered = filterErlangWarnings(result.stderr.toString()); - if (filtered) process.stderr.write(filtered + "\n"); - } - if (result.status !== 0) { - const err = new Error("Command failed: " + command); - err.status = result.status; - throw err; - } -} -function execOrThrow(command) { - if (command.startsWith("gleam ")) { - execGleamFiltered(command); - } else { - execSync(command, { stdio: "inherit", shell: true }); - } -} - -export function exec(command) { - try { - execOrThrow(command); - return new Ok(undefined); - } catch (error) { - return new GleamError(error); - } -} export function file_exists(path) { return existsSync(path); } @@ -554,7 +499,7 @@ export function fail_process() { function setupBridge(bindings) { generateBindingsOrThrow(bindings); - execGleamFiltered("gleam build"); + runFilteredCommandOrThrow("gleam build"); const pkg = JSON.parse(readFileSync("package.json", "utf-8")); const widgetName = pkg.widgetName; const widgets = pkg.widgets; @@ -1174,7 +1119,7 @@ function runDevWithBridgeUsing(execBuild, bindings) { if (!hasChanges()) return; console.log("\n[glendix] 변경 감지 → 리빌드"); try { - execGleamFiltered("gleam build"); + runFilteredCommandOrThrow("gleam build"); execBuild(); console.log("[glendix] 빌드 완료"); } catch (error) { diff --git a/src/glendix/command.gleam b/src/glendix/command.gleam new file mode 100644 index 0000000..77dab89 --- /dev/null +++ b/src/glendix/command.gleam @@ -0,0 +1,101 @@ +//// Runs synchronous shell commands while preserving Glendix CLI semantics. +//// + +import gleam/result +import gleam/string +import shellout + +/// Describes a synchronous command execution failure. +@internal +pub type CommandFailure { + /// A command failed with the supplied process-boundary reason. + CommandFailed(command: String, reason: String) +} + +/// Runs a shell command synchronously with inherited standard streams. +@internal +pub fn run(command command: String) -> Result(Nil, CommandFailure) { + case string.starts_with(command, "gleam ") { + True -> run_filtered(command) + False -> run_with_shellout(command) + } +} + +/// Returns the compatibility error message for a failed command. +@internal +pub fn error_message(failure failure: CommandFailure) -> String { + let CommandFailed(reason:, ..) = failure + reason +} + +type RawCommandError + +type SigintListeners + +fn run_with_shellout(command: String) -> Result(Nil, CommandFailure) { + let listeners = capture_sigint_listeners() + let execution = + shellout.command( + run: shell_executable(), + with: shell_arguments(command), + in: ".", + opt: [shellout.LetBeStdout, shellout.LetBeStderr], + ) + restore_sigint_listeners(listeners) + execution + |> result.map(fn(_) { Nil }) + |> result.map_error(fn(error) { + let #(_status, output) = error + CommandFailed( + command: command, + reason: shellout_error_message(command, output), + ) + }) +} + +fn shell_executable() -> String { + case is_windows() { + True -> windows_shell() + False -> "/bin/sh" + } +} + +fn shell_arguments(command: String) -> List(String) { + case is_windows() { + True -> ["/d", "/s", "/c", command] + False -> ["-c", command] + } +} + +fn shellout_error_message(command: String, output: String) -> String { + let summary = "Command failed: " <> command + case string.trim(output) { + "" -> summary + detail -> summary <> "\n" <> detail + } +} + +fn run_filtered(command: String) -> Result(Nil, CommandFailure) { + run_filtered_raw(command) + |> result.map_error(fn(error) { + CommandFailed(command: command, reason: raw_error_message(error)) + }) +} + +@external(javascript, "./command_ffi.mjs", "is_windows") +fn is_windows() -> Bool + +@external(javascript, "./command_ffi.mjs", "windows_shell") +fn windows_shell() -> String + +@external(javascript, "./command_ffi.mjs", "capture_sigint_listeners") +fn capture_sigint_listeners() -> SigintListeners + +@external(javascript, "./command_ffi.mjs", "restore_sigint_listeners") +fn restore_sigint_listeners(listeners: SigintListeners) -> Nil + +@external(javascript, "./command_ffi.mjs", "run_filtered") +fn run_filtered_raw(command: String) -> Result(Nil, RawCommandError) + +@external(javascript, "./command_ffi.mjs", "error_message") +fn raw_error_message(error: RawCommandError) -> String diff --git a/src/glendix/command_ffi.mjs b/src/glendix/command_ffi.mjs new file mode 100644 index 0000000..91fda49 --- /dev/null +++ b/src/glendix/command_ffi.mjs @@ -0,0 +1,87 @@ +import { spawnSync } from "node:child_process"; +import process from "node:process"; +import { Ok, Error as GleamError } from "../gleam.mjs"; + +function filterErlangWarnings(stderr) { + const lines = stderr.split(/\r?\n/); + const result = []; + let skip = false; + let skipNextEmpty = false; + for (let index = 0; index < lines.length; index += 1) { + if (!skip && lines[index] === "warning: Unused value") { + if ( + index + 1 < lines.length + && lines[index + 1].includes("gleam_erlang") + ) { + skip = true; + continue; + } + } + if (skip) { + if (lines[index].includes("not needed")) { + skip = false; + skipNextEmpty = true; + } + continue; + } + if (skipNextEmpty) { + skipNextEmpty = false; + if (lines[index].trim() === "") continue; + } + result.push(lines[index]); + } + return result.join("\n").replace(/\n{3,}/g, "\n\n").trim(); +} + +function runFilteredCommandOrThrow(command) { + const result = spawnSync(command, { + shell: true, + stdio: ["inherit", "pipe", "pipe"], + }); + if (result.stdout && result.stdout.length > 0) { + process.stdout.write(result.stdout); + } + if (result.stderr && result.stderr.length > 0) { + const filtered = filterErlangWarnings(result.stderr.toString()); + if (filtered) process.stderr.write(filtered + "\n"); + } + if (result.status !== 0) { + const error = new Error("Command failed: " + command); + error.status = result.status; + throw error; + } +} + +export { runFilteredCommandOrThrow }; + +export function is_windows() { + return globalThis.Deno?.build?.os === "windows" || process.platform === "win32"; +} + +export function windows_shell() { + return process.env.ComSpec ?? process.env.COMSPEC ?? "cmd.exe"; +} + +export function capture_sigint_listeners() { + return process.rawListeners("SIGINT"); +} + +export function restore_sigint_listeners(listeners) { + const retained = new Set(listeners); + for (const listener of process.rawListeners("SIGINT")) { + if (!retained.has(listener)) process.removeListener("SIGINT", listener); + } +} + +export function run_filtered(command) { + try { + runFilteredCommandOrThrow(command); + return new Ok(undefined); + } catch (error) { + return new GleamError(error); + } +} + +export function error_message(error) { + return error instanceof globalThis.Error ? error.message : String(error); +} diff --git a/test/glendix/command_test.gleam b/test/glendix/command_test.gleam new file mode 100644 index 0000000..9d171cf --- /dev/null +++ b/test/glendix/command_test.gleam @@ -0,0 +1,69 @@ +//// Verifies synchronous command execution and error contracts. +//// + +import gleam/string +import gleeunit/should +import glendix/cmd + +/// Verifies an empty shell command preserves its successful no-op behavior. +pub fn empty_command_succeeds_test() -> Nil { + cmd.exec(command: "") + |> should.be_ok +} + +/// Verifies command strings retain shell parsing and operator behavior. +pub fn shell_command_string_succeeds_test() -> Nil { + cmd.exec( + command: "node -e \"process.exit(0)\" && node -e \"process.exit(0)\"", + ) + |> should.be_ok +} + +/// Verifies a nonzero generic command returns the public command error. +pub fn shell_command_failure_surfaces_reason_test() -> Nil { + let source = "node -e \"process.exit(7)\"" + case cmd.exec(command: source) { + Error(cmd.CommandFailed(operation, reason)) -> { + operation + |> should.equal("execute command") + reason + |> should.equal("Command failed: " <> source) + } + Ok(_) -> should.fail() + } +} + +/// Verifies shellout's inherited-stream listener does not leak after commands. +pub fn shell_command_cleans_up_sigint_listeners_test() -> Nil { + let initial_count = sigint_listener_count() + cmd.exec(command: "") + |> should.be_ok + cmd.exec(command: "node -e \"process.exit(9)\"") + |> should.be_error + sigint_listener_count() + |> should.equal(initial_count) +} + +/// Verifies the filtered Gleam command path remains synchronous and successful. +pub fn filtered_gleam_command_succeeds_test() -> Nil { + cmd.exec(command: "gleam --version") + |> should.be_ok +} + +/// Verifies the filtered Gleam command path preserves its failure message. +pub fn filtered_gleam_command_failure_surfaces_reason_test() -> Nil { + let source = "gleam command-that-does-not-exist" + case cmd.exec(command: source) { + Error(cmd.CommandFailed(operation, reason)) -> { + operation + |> should.equal("execute command") + reason + |> string.contains(source) + |> should.be_true + } + Ok(_) -> should.fail() + } +} + +@external(javascript, "./command_test_ffi.mjs", "sigint_listener_count") +fn sigint_listener_count() -> Int diff --git a/test/glendix/command_test_ffi.mjs b/test/glendix/command_test_ffi.mjs new file mode 100644 index 0000000..9f446d7 --- /dev/null +++ b/test/glendix/command_test_ffi.mjs @@ -0,0 +1,5 @@ +import process from "node:process"; + +export function sigint_listener_count() { + return process.listenerCount("SIGINT"); +}