authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 03:42:49-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 03:42:49-07:00
log39802c06117fae0b3da43624b0d54309c5437c72
tree445d4d147d459d00075160e5e3f7e7cc8c665c69
parent2271a27dad1205b403a66af01461f856986e5064
signature Signed by PGP key B5690EEEBB952194

fix(ssh): report remote package installation failures accurately (#10088)


2 files changed, 144 insertions(+), 1 deletions(-)

packages/ssh/src/runnerProcess.test.ts+140
...@@ -165,3 +165,143 @@ if (args.includes("--package")) {...@@ -165,3 +165,143 @@ if (args.includes("--package")) {
165 );165 );
166 },166 },
167);167);
168
169describe.skipIf(HostProcessPlatform.defaultValue() === "win32")(
170 "remote runner install diagnostics",
171 () => {
172 const decodeArguments = Schema.decodeUnknownSync(
173 Schema.fromJsonString(Schema.Array(Schema.String)),
174 );
175 const cases = (["npx", "npm"] as const).flatMap((packageManager) =>
176 (
177 [
178 "etarget",
179 "network",
180 "empty-success",
181 "success",
182 "failed-with-path",
183 "existing-cli",
184 "node-override",
185 ] as const
186 ).map((mode) => ({ packageManager, mode })),
187 );
188
189 it.live.each(cases)("handles $packageManager/$mode", ({ packageManager, mode }) =>
190 Effect.gen(function* () {
191 const fs = yield* FileSystem.FileSystem;
192 const path = yield* Path.Path;
193 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
194 const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-install-" });
195 const bin = path.join(fixture, "bin");
196 const cliPath = path.join(fixture, "installed cli.mjs");
197 const callsPath = path.join(fixture, "installer-calls.jsonl");
198 const packageSpec = "t3@0.0.39-nightly.20260905.1286";
199 const args = ["serve", "a path with spaces"];
200 yield* fs.makeDirectory(bin);
201 yield* fs.symlink(process.execPath, path.join(bin, "node"));
202 yield* fs.writeFileString(
203 cliPath,
204 `#!/usr/bin/env node
205process.stdout.write(JSON.stringify(process.argv.slice(2)) + "\\n");
206`,
207 );
208 yield* fs.chmod(cliPath, 0o700);
209 yield* fs.writeFileString(callsPath, "");
210 yield* fs.writeFileString(
211 path.join(bin, packageManager),
212 `#!/usr/bin/env node
213const fs = require("node:fs");
214fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n");
215const mode = process.env.T3_TEST_MODE;
216if (mode === "success" || mode === "failed-with-path") {
217 process.stdout.write(process.env.T3_TEST_CLI + "\\n");
218}
219if (mode === "etarget" || mode === "failed-with-path") {
220 process.stderr.write("npm error code ETARGET\\nnpm error notarget No matching version found.\\n");
221 process.exitCode = 42;
222} else if (mode === "network") {
223 process.stderr.write("npm error code ENETUNREACH\\n");
224 process.exitCode = 43;
225}
226`,
227 );
228 yield* fs.chmod(path.join(bin, packageManager), 0o700);
229 if (mode === "existing-cli") yield* fs.symlink(cliPath, path.join(bin, "t3"));
230
231 const child = yield* spawner.spawn(
232 ChildProcess.make("/bin/sh", ["-s", "--", ...args], {
233 cwd: fixture,
234 extendEnv: false,
235 env: {
236 PATH: bin,
237 T3_TEST_MODE: mode,
238 T3_TEST_CLI: cliPath,
239 T3_TEST_CALLS: callsPath,
240 },
241 stdin: Stream.make(
242 new TextEncoder().encode(
243 buildRemoteT3RunnerScript({
244 packageSpec,
245 ...(mode === "node-override" ? { nodeScriptPath: cliPath } : {}),
246 }),
247 ),
248 ),
249 }),
250 );
251 const { stdout, stderr, exitCode } = yield* Effect.all(
252 {
253 stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString),
254 stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString),
255 exitCode: child.exitCode,
256 },
257 { concurrency: "unbounded" },
258 );
259 const installFailed =
260 mode === "etarget" || mode === "network" || mode === "failed-with-path";
261 const missingExecutable = mode === "empty-success";
262 assert.equal(exitCode, installFailed || missingExecutable ? 1 : 0);
263 if (installFailed || missingExecutable) {
264 assert.equal(stdout, "");
265 } else {
266 assert.deepEqual(decodeArguments(stdout), args);
267 }
268 if (installFailed) {
269 const npmError = mode === "network" ? "ENETUNREACH" : "ETARGET";
270 assert.include(stderr, `npm error code ${npmError}\n`);
271 assert.include(stderr, `Remote host could not install ${packageSpec}.`);
272 assert.notInclude(stderr, "Remote host installed");
273 assert.notInclude(stderr, "Install a C toolchain");
274 } else if (missingExecutable) {
275 assert.include(stderr, `Remote host installed ${packageSpec}`);
276 assert.include(stderr, "npm produced no t3 executable");
277 assert.include(stderr, "Install a C toolchain");
278 } else {
279 assert.equal(stderr, "");
280 }
281 const expectedCall = [
282 ...(packageManager === "npm" ? ["exec"] : []),
283 "--yes",
284 "--package",
285 packageSpec,
286 "--",
287 "sh",
288 "-c",
289 "command -v t3",
290 ];
291 const usesInstaller = mode !== "existing-cli" && mode !== "node-override";
292 const calls = yield* fs.readFileString(callsPath);
293 if (usesInstaller) {
294 assert.deepEqual(
295 calls
296 .trim()
297 .split("\n")
298 .map((line) => decodeArguments(line)),
299 [expectedCall],
300 );
301 } else {
302 assert.equal(calls, "");
303 }
304 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
305 );
306 },
307);
packages/ssh/src/tunnel.ts+4-1
...@@ -433,7 +433,10 @@ fi...@@ -433,7 +433,10 @@ fi
433# never becomes ready. Resolve the CLI once up front so that install failure is433# never becomes ready. Resolve the CLI once up front so that install failure is
434# reported here, with npm's own output on stderr.434# reported here, with npm's own output on stderr.
435require_installed_t3_cli() {435require_installed_t3_cli() {
436 T3_CLI_PATH="$("$@" -- sh -c 'command -v t3' || true)"436 if ! T3_CLI_PATH="$("$@" -- sh -c 'command -v t3')"; then
437 printf 'Remote host could not install %s. See npm output above for the cause.\\n' @@T3_PACKAGE_SPEC@@ >&2
438 return 1
439 fi
437 if [ -n "$T3_CLI_PATH" ]; then440 if [ -n "$T3_CLI_PATH" ]; then
438 return 0441 return 0
439 fi442 fi