litmus-cli 1.4.28 → 1.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/commands/connect.d.ts +349 -7
  2. package/dist/commands/connect.d.ts.map +1 -1
  3. package/dist/commands/connect.js +751 -58
  4. package/dist/commands/connect.js.map +1 -1
  5. package/dist/commands/doctor.d.ts +52 -0
  6. package/dist/commands/doctor.d.ts.map +1 -1
  7. package/dist/commands/doctor.js +76 -7
  8. package/dist/commands/doctor.js.map +1 -1
  9. package/dist/commands/init.d.ts.map +1 -1
  10. package/dist/commands/init.js +6 -2
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/pause.d.ts +13 -8
  13. package/dist/commands/pause.d.ts.map +1 -1
  14. package/dist/commands/pause.js +18 -10
  15. package/dist/commands/pause.js.map +1 -1
  16. package/dist/commands/push.d.ts.map +1 -1
  17. package/dist/commands/push.js +6 -2
  18. package/dist/commands/push.js.map +1 -1
  19. package/dist/index.js +2 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/lib/api-base.d.ts +80 -0
  22. package/dist/lib/api-base.d.ts.map +1 -1
  23. package/dist/lib/api-base.js +83 -0
  24. package/dist/lib/api-base.js.map +1 -1
  25. package/dist/lib/editor-binary.d.ts +110 -0
  26. package/dist/lib/editor-binary.d.ts.map +1 -0
  27. package/dist/lib/editor-binary.js +72 -0
  28. package/dist/lib/editor-binary.js.map +1 -0
  29. package/dist/lib/editor-ide.d.ts +11 -0
  30. package/dist/lib/editor-ide.d.ts.map +1 -0
  31. package/dist/lib/editor-ide.js +2 -0
  32. package/dist/lib/editor-ide.js.map +1 -0
  33. package/dist/lib/editor-server.d.ts +253 -0
  34. package/dist/lib/editor-server.d.ts.map +1 -0
  35. package/dist/lib/editor-server.js +286 -0
  36. package/dist/lib/editor-server.js.map +1 -0
  37. package/dist/lib/watcher.js +18 -1
  38. package/dist/lib/watcher.js.map +1 -1
  39. package/package.json +1 -1
@@ -1,13 +1,16 @@
1
1
  import os from "os";
2
2
  import path from "path";
3
3
  import readline from "readline";
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
+ import { closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "fs";
5
+ import { randomBytes } from "crypto";
5
6
  import { execFileSync, spawn, spawnSync } from "child_process";
6
7
  import chalk from "chalk";
7
8
  import ora from "ora";
8
9
  import { fetchInitMetadata, startWorkspace, getConnection, fetchEditorChatConfig, ServerError, hintForError } from "../lib/api.js";
9
10
  import { applyEditorChatConfig } from "../lib/chat-model-config.js";
10
11
  import { resolveApiBase } from "../lib/api-base.js";
12
+ import { editorInstallCandidates, editorSpawnSpec } from "../lib/editor-binary.js";
13
+ import { parseEditorVersion, parseWorkspaceProbe, remoteProbeCommand, seedVerdict, verifyVerdict, VERIFY_SCHEDULE_MS, } from "../lib/editor-server.js";
11
14
  import { fatal, success, info, warn, setErrorContext } from "../lib/errors.js";
12
15
  import { editorEndingForewarning } from "../lib/session-end.js";
13
16
  /**
@@ -249,7 +252,26 @@ function writeSshConfig(alias, block, controlBlock) {
249
252
  writeFileSync(cfgPath, upsertSshConfigContent(existing, alias, block, controlBlock), { mode: 0o600 });
250
253
  return cfgPath;
251
254
  }
252
- const NATIVE_EDITORS = {
255
+ /**
256
+ * The two editors, and the one place every measured difference between them lives.
257
+ *
258
+ * EXPORTED so the tests drive the SHIPPED record rather than literals they wrote
259
+ * themselves — an assertion against a fixture declared in the test file is a tautology,
260
+ * and `requiresSeedMatch`, `seedCommitPath` and `serverHomeDir` could all have been
261
+ * changed here with every test staying green.
262
+ *
263
+ * `seedCommitPath` and `serverHomeDir` are CROSS-TIER and their drift is silent: the
264
+ * first is `CACHE_ROOT` in `infra/codespaces-v2-image/scripts/install-{vscode,cursor}-
265
+ * server.sh`, the second the home subdir `litmus-firstboot.sh`'s `prewarm_editor` links
266
+ * into. A probe pointed at a path the image no longer writes reads "no seed" and "no
267
+ * logs" — which this module deliberately treats as missing inputs, so the launch
268
+ * proceeds unrefused and the verification stops being able to see a server. Nothing
269
+ * fails; the guarantee just quietly stops existing. `backend/tests/
270
+ * test_editor_server_paths.py` holds the pair, in pytest rather than vitest because the
271
+ * CLI suite is path-filtered on `cli/**` and an image-only edit is exactly the edit that
272
+ * has to fail.
273
+ */
274
+ export const NATIVE_EDITORS = {
253
275
  vscode: {
254
276
  ide: "vscode",
255
277
  bin: "code",
@@ -257,6 +279,9 @@ const NATIVE_EDITORS = {
257
279
  label: "VS Code",
258
280
  remoteSshExt: "ms-vscode-remote.remote-ssh",
259
281
  shellCommand: "Shell Command: Install 'code' command",
282
+ seedCommitPath: "/opt/vscode-server-cache/COMMIT",
283
+ serverHomeDir: ".vscode-server",
284
+ requiresSeedMatch: false,
260
285
  },
261
286
  cursor: {
262
287
  ide: "cursor",
@@ -265,17 +290,61 @@ const NATIVE_EDITORS = {
265
290
  label: "Cursor",
266
291
  remoteSshExt: "anysphere.remote-ssh",
267
292
  shellCommand: "Shell Command: Install 'cursor' command",
293
+ seedCommitPath: "/opt/cursor-server-cache/COMMIT",
294
+ serverHomeDir: ".cursor-server",
295
+ requiresSeedMatch: true,
268
296
  },
269
297
  };
270
- function hasEditorCli(ed) {
298
+ /**
299
+ * Can this command be RUN, and what build does it say it is — one spawn for both.
300
+ *
301
+ * `--version` exits 0 on both editors and prints three lines that name neither of them
302
+ * (see `NATIVE_EDITORS`), so this proves existence and a COMMIT and must never drift back
303
+ * into standing for an identity; `identifyEditorCli` is that, and it still runs `--help`
304
+ * against whatever this resolves to.
305
+ *
306
+ * ENG-2332 — it takes a COMMAND rather than `ed.bin`, because the command may be an
307
+ * absolute path inside the application bundle, and it goes through `editorSpawnSpec` so
308
+ * that a Windows `code.cmd` is probed the way it would be driven. Probing one way and
309
+ * driving another is how a check passes for a command that cannot actually be launched.
310
+ *
311
+ * It returns the VERSION rather than a boolean because an existence probe that discards
312
+ * stdout only to have the caller spawn the same command again for the same output is one
313
+ * subprocess and one 10s timeout on the critical path for nothing — and, worse, two
314
+ * readings that can disagree about which binary answered. Null is "cannot be run"; a
315
+ * record with null fields is "it ran and said nothing we could read", which is a missing
316
+ * input for `seedVerdict` and not an absent editor.
317
+ */
318
+ function probeEditorCli(command) {
319
+ const spec = editorSpawnSpec(command, ["--version"], process.platform);
320
+ // No argv can drive this command on this platform (a `%` in the path, on Windows) — the
321
+ // same answer as a command that will not run. See `editorSpawnSpec`; throwing here is
322
+ // what aborted the connection for a candidate whose username contained a `%`.
323
+ if (!spec)
324
+ return null;
271
325
  try {
272
- execFileSync(ed.bin, ["--version"], { stdio: "ignore" });
273
- return true;
326
+ const probed = spawnSync(spec.command, spec.args, { encoding: "utf8", shell: spec.shell, timeout: 10000 });
327
+ if (probed.error || probed.status !== 0)
328
+ return null;
329
+ return parseEditorVersion(probed.stdout ?? null);
274
330
  }
275
331
  catch {
276
- return false;
332
+ return null;
277
333
  }
278
334
  }
335
+ function resolveEditorCommand(ed) {
336
+ const onPath = probeEditorCli(ed.bin);
337
+ if (onPath)
338
+ return { command: ed.bin, source: "path", version: onPath };
339
+ for (const candidate of editorInstallCandidates(ed.ide, process.platform, process.env, os.homedir())) {
340
+ if (!existsSync(candidate))
341
+ continue;
342
+ const version = probeEditorCli(candidate);
343
+ if (version)
344
+ return { command: candidate, source: "install-location", version };
345
+ }
346
+ return null;
347
+ }
279
348
  /**
280
349
  * Map `--help` output to an editor we know, or null.
281
350
  *
@@ -314,11 +383,16 @@ export function identifyEditorBanner(helpOutput) {
314
383
  * stdout only — a chatty wrapper's stderr must not become an identity — and a timeout,
315
384
  * so a hung binary cannot wedge connect.
316
385
  */
317
- function runEditorHelp(bin) {
386
+ function runEditorHelp(command) {
387
+ const spec = editorSpawnSpec(command, ["--help"], process.platform);
388
+ // Undrivable reads as "could not ask", which `identifyEditorCli` turns into
389
+ // `unverified` — the direction this probe is already required to fail in.
390
+ if (!spec)
391
+ return null;
318
392
  try {
319
- const probed = spawnSync(bin, ["--help"], {
393
+ const probed = spawnSync(spec.command, spec.args, {
320
394
  encoding: "utf8",
321
- shell: process.platform === "win32",
395
+ shell: spec.shell,
322
396
  timeout: 10000,
323
397
  });
324
398
  if (probed.error)
@@ -330,8 +404,8 @@ function runEditorHelp(bin) {
330
404
  return null;
331
405
  }
332
406
  }
333
- export function identifyEditorCli(ed, run = runEditorHelp) {
334
- const out = run(ed.bin);
407
+ export function identifyEditorCli(ed, command, run = runEditorHelp) {
408
+ const out = run(command);
335
409
  if (out === null)
336
410
  return { kind: "unverified" };
337
411
  const actual = identifyEditorBanner(out);
@@ -389,37 +463,552 @@ export function editorMismatchLines(chosen, actual, reentry = CURSOR_REENTRY_FAL
389
463
  lines.push("");
390
464
  return lines;
391
465
  }
466
+ /**
467
+ * ENG-2332 — the refusal when the candidate's editor is a different build from the one
468
+ * this workspace has, and the route to the workspace that still works.
469
+ *
470
+ * It says what was compared and what it means, because "version mismatch" on its own
471
+ * reads as pedantry and a candidate mid-assessment will look for a way round it. What it
472
+ * must never do is offer one: there is no flag, no retry and no "open it anyway" here,
473
+ * since the thing on the other side of that door is a window that looks completely normal
474
+ * and records nothing (`lib/editor-server.ts` carries the measurement).
475
+ *
476
+ * The browser IDE leads, because it is the same container, the same files and the same
477
+ * capture, and it is the one route that cannot have this fault — it is served FROM the
478
+ * workspace rather than fetched INTO it. When the workspace publishes no URL the fallback
479
+ * is the terminal, which is a real way to work and not a consolation.
480
+ */
481
+ export function editorSeedMismatchLines(ed, verdict, browserIdeUrl, alias) {
482
+ const short = (c) => c.slice(0, 12);
483
+ const width = Math.max(`Your ${ed.label}:`.length, "This workspace:".length) + 1;
484
+ const lines = [
485
+ "",
486
+ chalk.yellow.bold(` ⚠ Your ${ed.label} is a different build from the one this workspace holds.`),
487
+ "",
488
+ ` ${`Your ${ed.label}:`.padEnd(width)}${verdict.clientVersion ?? "unknown version"} (${short(verdict.clientCommit)})`,
489
+ ` ${"This workspace:".padEnd(width)}${short(verdict.seedCommit)}`,
490
+ "",
491
+ ` ${ed.label} runs its own matching server inside the workspace, and downloads one`,
492
+ " when the build does not match. This workspace has no route out to the internet,",
493
+ " so that download cannot happen — and when it fails, a window still opens. It",
494
+ " looks completely normal, it is not attached to your workspace, and anything you",
495
+ chalk.yellow(" do in it is not recorded."),
496
+ "",
497
+ ` So nothing has been opened. Your workspace is up and waiting — nothing is lost.`,
498
+ "",
499
+ ];
500
+ if (browserIdeUrl) {
501
+ lines.push(" Use the browser IDE instead — same workspace, same files, fully recorded:");
502
+ lines.push(` ${chalk.cyan(browserIdeUrl)}`);
503
+ lines.push("");
504
+ lines.push(chalk.dim(" (It is also on your assessment page, so you do not need to keep this link.)"));
505
+ }
506
+ else {
507
+ lines.push(" Open your assessment page and use the browser IDE there — same workspace,");
508
+ lines.push(" same files, fully recorded. To work from a terminal instead:");
509
+ lines.push("");
510
+ lines.push(` ${chalk.cyan(`ssh ${alias}`)}`);
511
+ }
512
+ lines.push("");
513
+ return lines;
514
+ }
515
+ /**
516
+ * ENG-2332 — what to say when the editor CLI cannot be found anywhere.
517
+ *
518
+ * The line this replaces offered `<bin> --remote ssh-remote+<alias> …` as the
519
+ * remediation for `<bin>` not existing. Everything here is something the candidate can do
520
+ * WITHOUT that command: the editor's own Remote-SSH picker (the alias is already in their
521
+ * `~/.ssh/config`), the palette command that would put the CLI on PATH, and the browser
522
+ * IDE, which needs nothing installed at all.
523
+ */
524
+ export function editorNotFoundLines(ed, alias, browserIdeUrl) {
525
+ const lines = [
526
+ "",
527
+ chalk.yellow.bold(` ⚠ Couldn't find ${ed.label}'s \`${ed.bin}\` command on this machine.`),
528
+ "",
529
+ ` It is not on your PATH and it is not where ${ed.label}'s installer puts it, so`,
530
+ " nothing has been opened. Your workspace is up and waiting — nothing is lost.",
531
+ "",
532
+ ` Your SSH config is already written, so from inside ${ed.label} you can connect`,
533
+ " with no command line at all:",
534
+ "",
535
+ ` Command Palette → ${chalk.bold("Remote-SSH: Connect to Host…")} → ${chalk.bold(alias)}`,
536
+ "",
537
+ ` (Needs the "${ed.remoteSshExt}" extension, which ${ed.label} will offer to install.)`,
538
+ "",
539
+ ` To get the \`${ed.bin}\` command for next time: in ${ed.label}, Command Palette →`,
540
+ ` "${ed.shellCommand}", then open a NEW terminal.`,
541
+ ];
542
+ if (browserIdeUrl) {
543
+ lines.push("");
544
+ lines.push(" Or skip the editor entirely — the browser IDE needs nothing installed:");
545
+ lines.push(` ${chalk.cyan(browserIdeUrl)}`);
546
+ }
547
+ lines.push("");
548
+ return lines;
549
+ }
550
+ /**
551
+ * ENG-2332 — the positive signal, which the CLI had none of.
552
+ *
553
+ * It states exactly what was verified and not one step more. A server running in the
554
+ * workspace means the window is editing the container's files, so the file, terminal and
555
+ * commit lanes are live — that is what "recorded" may claim here.
556
+ *
557
+ * IT MAY NOT CLAIM AI CAPTURE ON CURSOR, and that is not hedging. Cursor reads its hooks
558
+ * configuration once, at application startup (ENG-1728, measured): a candidate who
559
+ * connected from an already-running Cursor has a live, correct, never-loaded hook, and
560
+ * their prompts are not recorded although everything else is. The verification above
561
+ * cannot see that — a server is up either way — so a flat "your work is being recorded"
562
+ * would be the same false reassurance this ticket is about, moved one lane along. The
563
+ * full notice is printed at selection time; this restates only the dependency.
564
+ */
565
+ export function editorConnectedLines(ed, alias) {
566
+ const lines = [chalk.dim(` Workspace: ${alias}:${REMOTE_WORKSPACE_DIR}`)];
567
+ if (ed.ide === "cursor") {
568
+ lines.push(chalk.dim(" Your AI prompts are recorded only if you quit Cursor completely before this opened;"));
569
+ lines.push(chalk.dim(" reloading the window or reconnecting does not load the hook."));
570
+ }
571
+ return lines;
572
+ }
573
+ /**
574
+ * ENG-2332 — what to say when a window opened and no server came up in the workspace.
575
+ *
576
+ * This is the copy the ticket was opened about. It used to be
577
+ * "If a new window doesn't appear, run litmus connect again" — which is wrong twice: a
578
+ * window DOES appear in this failure, and re-running connect cannot change the state,
579
+ * which is exactly what had the reporter run it four times. So it names what was
580
+ * observed, and every route it offers is one this failure does not block.
581
+ *
582
+ * `seed` rides along because on VS Code a mismatch is not a refusal (see
583
+ * `requiresSeedMatch`) — so when a VS Code launch does fail, the build difference is the
584
+ * first thing worth knowing, and it is already in hand.
585
+ *
586
+ * WHAT IT MAY NOT SAY IS THAT NOTHING WILL CHANGE. That is what it said first, and it is
587
+ * a claim about the future made on a reading that stops at the verification budget. A VS
588
+ * Code client whose build the image did not bake is NOT refused (`requiresSeedMatch`), so
589
+ * its launch legitimately goes on to fetch a server through the mirror — a ~28.6 MB CLI
590
+ * and, on a prewarm miss, the whole bundle — and a slow link can still be fetching when
591
+ * the budget runs out. Telling that candidate their window is dead and pointing them
592
+ * elsewhere is the harm `unverified` exists to avoid, arriving on the branch below it.
593
+ * So it states the bound it actually waited (derived from `VERIFY_SCHEDULE_MS`, never a
594
+ * second hard-coded copy), says the window may yet attach, and offers the other two
595
+ * routes rather than instructing an exit. What it still may not do is tell them to re-run
596
+ * this command, which is the original bug.
597
+ *
598
+ * AND IT MAY NOT HAND THEM A CHECK THAT PASSES ON THE DEAD WINDOW. It pointed at the
599
+ * remote indicator — "the bottom-left corner reads SSH: <alias>" — and this repo has
600
+ * measured that indicator PRESENT in exactly the failure case: `lib/editor-server.ts`'s
601
+ * header records the never-attached window carrying `"remoteAuthority":
602
+ * "ssh-remote+<alias>"` and being indistinguishable from a working one, and
603
+ * `lib/session-end.ts` records (ENG-2154) that a window whose transport is gone still
604
+ * reads `[SSH: <host>]`. A candidate told to look at the corner sees what they were told
605
+ * to look for and goes on working unrecorded, which is this ticket's own bug re-entering
606
+ * through its remediation. The honest sentence is that the two windows look the same —
607
+ * that is WHY the CLI checks — so the copy says so and names no visual test at all.
608
+ */
609
+ export function editorNotConnectedLines(ed, alias, browserIdeUrl, seed, launchLog) {
610
+ const waited = Math.round(VERIFY_SCHEDULE_MS[VERIFY_SCHEDULE_MS.length - 1] / 1000);
611
+ const lines = [
612
+ "",
613
+ chalk.yellow.bold(` ⚠ ${ed.label} opened, but it never started a server in your workspace.`),
614
+ "",
615
+ ` A ${ed.label} window is on screen and it is NOT attached to your workspace: it is`,
616
+ " running against your own machine. Anything you do in that window is work on your",
617
+ chalk.yellow(" laptop, and it is not recorded."),
618
+ "",
619
+ ` We watched your workspace for ${waited} seconds after opening it and nothing`,
620
+ ` started there. A first-ever ${ed.label} connection can still be downloading its`,
621
+ " server at that point and attach a little later. There is nothing you can check",
622
+ " by eye: an attached window and an unattached one look exactly the same, which is",
623
+ " why this command checks your workspace instead of asking you to.",
624
+ "",
625
+ ];
626
+ if (seed.kind === "mismatch") {
627
+ lines.push(` The likely reason: your ${ed.label} is build ${seed.clientCommit.slice(0, 12)} and this`);
628
+ lines.push(` workspace holds ${seed.seedCommit.slice(0, 12)}. It could not fetch a matching one.`);
629
+ lines.push("");
630
+ }
631
+ if (browserIdeUrl) {
632
+ lines.push(" You can work in the browser IDE instead — same workspace, same files, fully");
633
+ lines.push(" recorded, and nothing to install:");
634
+ lines.push(` ${chalk.cyan(browserIdeUrl)}`);
635
+ }
636
+ else {
637
+ lines.push(" You can work in the browser IDE instead, from your assessment page — same");
638
+ lines.push(" workspace, same files, fully recorded.");
639
+ }
640
+ lines.push("");
641
+ lines.push(" You can also work from a terminal, which does not need the editor at all:");
642
+ lines.push(` ${chalk.cyan(`ssh ${alias}`)}`);
643
+ if (launchLog) {
644
+ lines.push("");
645
+ lines.push(chalk.dim(` ${ed.label} said, while trying to open:`));
646
+ for (const line of launchLog.split(/\r?\n/).slice(-8))
647
+ lines.push(chalk.dim(` ${line}`));
648
+ }
649
+ lines.push("");
650
+ return lines;
651
+ }
652
+ /**
653
+ * ENG-2332 — what to say when the editor never started at all.
654
+ *
655
+ * THIS BRANCH EXISTS BECAUSE THE ONE ABOVE MUST NOT BE REACHABLE WITHOUT A WINDOW.
656
+ * `editorNotConnectedLines` opens by stating, as a fact, that a window is on screen and
657
+ * running against the candidate's own machine; when `spawn` itself fails that sentence is
658
+ * false, and the reading the verification produces is identical either way (no server,
659
+ * empty launch log). So the spawn error is recorded (`EditorLaunchHandle`) and routed
660
+ * here instead, where the only claim made is the one we can support.
661
+ *
662
+ * It offers the same two routes and, like every other failure branch, offers rather than
663
+ * instructs: there is no window to close, and re-running this command is the remediation
664
+ * the ticket was opened about. The error text is carried verbatim because it is the whole
665
+ * of what we know — `ENOENT` on a resolved path means the app moved or was removed since
666
+ * the `--version` probe a moment earlier, which is a thing the candidate can act on and
667
+ * we cannot diagnose for them.
668
+ */
669
+ export function editorLaunchFailedLines(ed, alias, browserIdeUrl, error, launchLog) {
670
+ const lines = [
671
+ "",
672
+ chalk.yellow.bold(` ⚠ ${ed.label} could not be started, so no window was opened.`),
673
+ "",
674
+ ` We found the ${ed.label} command and it answered a moment ago, but running it`,
675
+ " failed:",
676
+ ` ${chalk.dim(error)}`,
677
+ "",
678
+ ` That usually means ${ed.label} was moved, removed or updated between those two`,
679
+ " moments. Nothing has been opened and nothing is being recorded yet.",
680
+ "",
681
+ ];
682
+ if (browserIdeUrl) {
683
+ lines.push(" You can work in the browser IDE instead — same workspace, same files, fully");
684
+ lines.push(" recorded, and nothing to install:");
685
+ lines.push(` ${chalk.cyan(browserIdeUrl)}`);
686
+ }
687
+ else {
688
+ lines.push(" You can work in the browser IDE instead, from your assessment page — same");
689
+ lines.push(" workspace, same files, fully recorded.");
690
+ }
691
+ lines.push("");
692
+ lines.push(" You can also work from a terminal, which does not need the editor at all:");
693
+ lines.push(` ${chalk.cyan(`ssh ${alias}`)}`);
694
+ if (launchLog) {
695
+ lines.push("");
696
+ lines.push(chalk.dim(` ${ed.label} said, while trying to open:`));
697
+ for (const line of launchLog.split(/\r?\n/).slice(-8))
698
+ lines.push(chalk.dim(` ${line}`));
699
+ }
700
+ lines.push("");
701
+ return lines;
702
+ }
703
+ /**
704
+ * ENG-2332 — what to say when we could not reach the workspace to ask at all.
705
+ *
706
+ * Three-valued on purpose: "we could not check" is not "nothing came up", and saying
707
+ * either of the other two here would be a claim we cannot make. It lives beside the other
708
+ * copy rather than inline in `runConnect` so that what it may and may not say is testable
709
+ * — it is bound by the same rule as `editorNotConnectedLines` and broke it the same way.
710
+ *
711
+ * It may NOT tell the candidate to look at the remote indicator. That indicator is
712
+ * rendered from the remote authority, which the never-attached window carries (see
713
+ * `lib/editor-server.ts`'s header, and ENG-2154 in `lib/session-end.ts`), so the check
714
+ * passes in exactly the case it was offered to detect. It states the ambiguity instead
715
+ * and offers the two routes that are not in doubt.
716
+ */
717
+ export function editorUnverifiedLines(ed, alias, browserIdeUrl) {
718
+ const lines = [
719
+ ` Your ${ed.label} window may or may not be attached to your workspace, and there is`,
720
+ " nothing you can check by eye: an attached window and an unattached one look exactly",
721
+ ` the same. An unattached one runs on your own machine and is ${chalk.yellow("not recorded")}.`,
722
+ "",
723
+ ];
724
+ if (browserIdeUrl) {
725
+ lines.push(" You can work in the browser IDE instead — same workspace, same files, fully");
726
+ lines.push(" recorded, and nothing to install:");
727
+ lines.push(` ${chalk.cyan(browserIdeUrl)}`);
728
+ }
729
+ else {
730
+ lines.push(" You can work in the browser IDE instead, from your assessment page — same");
731
+ lines.push(" workspace, same files, fully recorded.");
732
+ }
733
+ lines.push("");
734
+ lines.push(" You can also work from a terminal, which does not need the editor at all:");
735
+ lines.push(` ${chalk.cyan(`ssh ${alias}`)}`);
736
+ return lines;
737
+ }
392
738
  /**
393
739
  * Ensure the Remote-SSH extension is installed in the user's editor before we
394
740
  * open the remote window (the `gh codespace code` model). Best-effort: a
395
741
  * marketplace/network failure returns "failed" rather than aborting connect —
396
742
  * the caller tells the user how to install it by hand. Assumes the editor's CLI
397
- * exists (caller gates on hasEditorCli()).
743
+ * has been resolved (caller gates on `resolveEditorCommand`).
398
744
  */
399
- function ensureRemoteSshExtension(ed) {
745
+ function ensureRemoteSshExtension(ed, command) {
746
+ const list = editorSpawnSpec(command, ["--list-extensions"], process.platform);
747
+ // Undrivable on this platform — the same outcome as an install that would not run.
748
+ // `failed` is already best-effort and never aborts connect.
749
+ if (!list)
750
+ return "failed";
400
751
  try {
401
- const list = execFileSync(ed.bin, ["--list-extensions"], { encoding: "utf8" });
402
- if (list.split(/\r?\n/).some((l) => l.trim().toLowerCase() === ed.remoteSshExt)) {
752
+ const listed = spawnSync(list.command, list.args, { encoding: "utf8", shell: list.shell, timeout: 60000 });
753
+ if (!listed.error && (listed.stdout ?? "").split(/\r?\n/).some((l) => l.trim().toLowerCase() === ed.remoteSshExt)) {
403
754
  return "present";
404
755
  }
405
756
  }
406
757
  catch {
407
758
  // Couldn't list (unexpected) — fall through and try to install anyway.
408
759
  }
760
+ const install = editorSpawnSpec(command, ["--install-extension", ed.remoteSshExt, "--force"], process.platform);
761
+ if (!install)
762
+ return "failed";
409
763
  try {
410
- execFileSync(ed.bin, ["--install-extension", ed.remoteSshExt, "--force"], { stdio: "ignore" });
411
- return "installed";
764
+ const done = spawnSync(install.command, install.args, { stdio: "ignore", shell: install.shell, timeout: 120000 });
765
+ return !done.error && done.status === 0 ? "installed" : "failed";
412
766
  }
413
767
  catch {
414
768
  return "failed";
415
769
  }
416
770
  }
417
- function launchEditor(ed, alias) {
418
- const child = spawn(ed.bin, ["--remote", `ssh-remote+${alias}`, REMOTE_WORKSPACE_DIR], {
771
+ /**
772
+ * ENG-2332 where the editor's own output goes.
773
+ *
774
+ * It used to go to `stdio: "ignore"`, which is the mechanical half of this ticket: a
775
+ * resolver that cannot fetch its server writes its reason here and nowhere else, so
776
+ * discarding it leaves exit code 0, an empty terminal and a dead window as the entire
777
+ * record of the failure.
778
+ *
779
+ * A FILE and not a pipe, and that is not a style choice. The child is `detached` and
780
+ * outlives this process by design; a pipe belongs to the parent, so once `litmus connect`
781
+ * returns the editor would be writing into a closed pipe and could take an EPIPE for it.
782
+ * A file descriptor on a real file has neither problem, survives our exit, and is still
783
+ * here when a candidate is asked for it.
784
+ *
785
+ * THE NAME IS PER RUN, and both reasons are real. `os.tmpdir()` is a shared `/tmp` on a
786
+ * Linux box with no `TMPDIR`, so a fixed name is a name another local user can create
787
+ * first — and `openSync(path, "w")` FOLLOWS a symlink and TRUNCATES, so a link planted
788
+ * there redirects the editor's output into, or empties, a file of theirs. And two
789
+ * `litmus connect` runs on one machine would otherwise share the file, so a candidate
790
+ * being walked through one failure can be shown the other run's output. The pid plus a
791
+ * random suffix answers both; `openEditorLaunchLog` then refuses to follow a link or to
792
+ * reuse an existing name, so an attacker who guesses one gets nothing rather than a
793
+ * write.
794
+ */
795
+ export function editorLaunchLogPath() {
796
+ const unique = randomBytes(6).toString("hex");
797
+ return path.join(os.tmpdir(), `litmus-connect-editor.${process.pid}.${unique}.log`);
798
+ }
799
+ /**
800
+ * Open the launch log for the child, or answer null when we cannot do it safely.
801
+ *
802
+ * `O_EXCL | O_NOFOLLOW` is the whole point: EXCL means an existing name — planted or
803
+ * left over — is refused rather than written into, and NOFOLLOW means a symlink at that
804
+ * name is refused rather than followed to its target. Neither is reachable in the
805
+ * ordinary case, since the name carries a random suffix; they are what makes the
806
+ * ordinary case's safety a property of the open rather than of the name being unguessed.
807
+ * A refusal costs the diagnostics from one launch and never the launch.
808
+ */
809
+ export function openEditorLaunchLog(logPath) {
810
+ try {
811
+ return openSync(logPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
812
+ }
813
+ catch {
814
+ return null;
815
+ }
816
+ }
817
+ /**
818
+ * ENG-2332, Greptile P1 on #2442 — ON WINDOWS THE `error` EVENT IS NOT THE FAILURE.
819
+ *
820
+ * `editorSpawnSpec` gives every Windows launch `shell: true`, so what `spawn` starts is
821
+ * `cmd.exe`, not the editor. `cmd.exe` starts fine essentially always — a missing `.cmd`,
822
+ * a path that stopped resolving, an editor that refuses its own argv are all reported by
823
+ * cmd.exe EXITING non-zero, not by an `error` event. So a handle watching only `error`
824
+ * reports "the window opened" for every one of them, which collapses the whole
825
+ * spawn-failed / opened-but-unattached distinction back into the wrong branch on exactly
826
+ * the platform the resolution work was added for.
827
+ *
828
+ * A NON-ZERO EXIT IS THEREFORE A LAUNCH FAILURE, and a zero exit is not. Both editors'
829
+ * CLIs return immediately after handing the argv to an already-running instance, so an
830
+ * ordinary successful launch exits 0 within moments and the process being gone says
831
+ * nothing. That asymmetry is what makes the exit code readable at all.
832
+ *
833
+ * The listeners outlive `unref()`: it stops the child holding the event loop open, and we
834
+ * are awaiting the verification anyway, so an exit inside the window still reaches us.
835
+ * Whichever signal arrives first wins and neither overwrites the other, because both
836
+ * describe the same thing — no window was created — and the first is the more specific.
837
+ */
838
+ export function launchEditor(ed, command, alias, logPath) {
839
+ const spec = editorSpawnSpec(command, ["--remote", `ssh-remote+${alias}`, REMOTE_WORKSPACE_DIR], process.platform);
840
+ if (!spec) {
841
+ // No argv drives this command here, so nothing was started and nothing will be. This
842
+ // is a launch failure rather than a throw for the reason `editorSpawnSpec` gives: the
843
+ // command came off the candidate's own filesystem, and their username is not a reason
844
+ // to abort a connection to a workspace that is already up.
845
+ const why = `no command line can start ${command} on this platform`;
846
+ return { launchFailure: () => why };
847
+ }
848
+ // A tmpdir we cannot write is not a reason to refuse to open the editor.
849
+ const out = openEditorLaunchLog(logPath) ?? "ignore";
850
+ const child = spawn(spec.command, spec.args, {
419
851
  detached: true,
420
- stdio: "ignore",
852
+ shell: spec.shell,
853
+ stdio: ["ignore", out, out],
854
+ });
855
+ // Node keeps its own copy of the descriptor for the child; ours is finished with the
856
+ // moment the spawn has it, and leaving it open would hold the file for our lifetime.
857
+ if (typeof out === "number") {
858
+ try {
859
+ closeSync(out);
860
+ }
861
+ catch {
862
+ /* already gone */
863
+ }
864
+ }
865
+ let failure = null;
866
+ const record = (why) => {
867
+ if (failure === null)
868
+ failure = why;
869
+ };
870
+ child.on("error", (err) => {
871
+ record(err.message || String(err));
872
+ });
873
+ child.on("exit", (code, signal) => {
874
+ if (signal !== null)
875
+ record(`${ed.label} was killed by ${signal} before it could start`);
876
+ else if (code !== null && code !== 0)
877
+ record(`${ed.label} exited immediately with code ${code}`);
421
878
  });
422
879
  child.unref();
880
+ return { launchFailure: () => failure };
881
+ }
882
+ /**
883
+ * Point at the editor's own output. `namePath` is for the branch where a human is
884
+ * walking a candidate through a failure: there the file is named whether or not
885
+ * `--verbose` was asked for, since `editorLaunchLogPath` gives it a per-run suffix that
886
+ * nobody can guess unaided.
887
+ *
888
+ * A PATH IS NAMED ONLY WHEN THERE IS SOMETHING AT IT, and the reason is that the file
889
+ * routinely does not exist. `launchEditor` falls back to `stdio: "ignore"` whenever
890
+ * `openEditorLaunchLog` refuses — an unwritable tmpdir, or its own `O_EXCL`/`O_NOFOLLOW`
891
+ * refusal — and even on the ordinary path the measured failure is an editor that exits 0
892
+ * and prints nothing, so an empty file is the common case rather than the odd one.
893
+ * Handing a support person a path with nothing behind it reads as the CLI having lost
894
+ * the log, which is a worse answer than saying plainly that the editor wrote nothing.
895
+ */
896
+ export function editorLaunchOutputLines(logPath, launchLog, verbose, namePath) {
897
+ if (!namePath && !verbose)
898
+ return [];
899
+ if (!launchLog)
900
+ return [chalk.dim(" The editor wrote no output while opening.")];
901
+ const lines = [chalk.dim(` Editor output: ${logPath}`)];
902
+ if (verbose)
903
+ for (const line of launchLog.split(/\r?\n/))
904
+ lines.push(chalk.dim(` ${line}`));
905
+ return lines;
906
+ }
907
+ /** What the editor wrote while trying to open, or null when it said nothing. */
908
+ function readEditorLaunchLog(logPath) {
909
+ try {
910
+ const text = readFileSync(logPath, "utf8").trim();
911
+ return text.length > 0 ? text : null;
912
+ }
913
+ catch {
914
+ return null;
915
+ }
916
+ }
917
+ function sshProbeRunner(alias) {
918
+ return (remoteCommand) => {
919
+ try {
920
+ const probed = spawnSync("ssh", ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "LogLevel=ERROR", alias, remoteCommand], { encoding: "utf8", timeout: 20000 });
921
+ if (probed.error)
922
+ return null;
923
+ return probed.stdout ?? null;
924
+ }
925
+ catch {
926
+ return null;
927
+ }
928
+ };
929
+ }
930
+ function probeWorkspace(ed, run) {
931
+ return parseWorkspaceProbe(run(remoteProbeCommand(ed.serverHomeDir, ed.seedCommitPath)));
932
+ }
933
+ /**
934
+ * ENG-2332 — poll the workspace until a server appears, or the budget runs out.
935
+ *
936
+ * Returns as soon as there is evidence, so the ordinary case costs one probe and a few
937
+ * seconds; the schedule is what bounds the other case (`VERIFY_SCHEDULE_MS` carries why
938
+ * it is a widening list rather than a fixed interval).
939
+ *
940
+ * A reading that FAILED and a reading that said "nothing here" are kept apart to the end:
941
+ * one probe answering over a flaky link must not turn a run that has already read the
942
+ * workspace several times into "we could not tell". So `not-connected` needs a probe
943
+ * that ANSWERED IT — the fallback below carries `verifyVerdict`'s own verdict forward
944
+ * rather than re-deriving one from "did anything read at all", which is a second copy of
945
+ * the same reasoning and was already one clause short of it: with no baseline the log
946
+ * signal is unusable, so a successful reading of an empty workspace is not evidence of
947
+ * anything and must not become `not-connected` here after `verifyVerdict` has just
948
+ * refused to call it that.
949
+ *
950
+ * A FAILED SPAWN ENDS THE WAIT IMMEDIATELY. The whole budget is spent waiting for a
951
+ * window to start a server, and a window that was never created will not — so the poll
952
+ * stops the moment `launched.launchFailure()` answers, rather than charging the candidate
953
+ * ~60 seconds for a question already settled. The wait is unchanged for every other
954
+ * case. It cannot be checked once up front: `spawn` reports the error on a later tick,
955
+ * so the first check has to come after an await, which is why the sleep is sliced rather
956
+ * than taken whole.
957
+ */
958
+ const SPAWN_ERROR_POLL_MS = 200;
959
+ export async function verifyEditorAttached(ed, baseline, run, onWait, launched) {
960
+ const started = Date.now();
961
+ const budget = VERIFY_SCHEDULE_MS[VERIFY_SCHEDULE_MS.length - 1];
962
+ let sawNotConnected = false;
963
+ for (const at of VERIFY_SCHEDULE_MS) {
964
+ const remaining = at - (Date.now() - started);
965
+ // A probe that spends its own timeout can carry the elapsed time past a later entry;
966
+ // skipping those keeps the WAIT the candidate experiences at the stated budget rather
967
+ // than at the budget plus however many probes went slowly.
968
+ if (remaining <= -1000 && at < budget)
969
+ continue;
970
+ if (remaining > 0) {
971
+ const until = Date.now() + remaining;
972
+ for (let left = remaining; left > 0; left = until - Date.now()) {
973
+ await sleep(Math.min(SPAWN_ERROR_POLL_MS, left));
974
+ if (launched.launchFailure() !== null)
975
+ return { kind: "unverified" };
976
+ }
977
+ }
978
+ if (launched.launchFailure() !== null)
979
+ return { kind: "unverified" };
980
+ onWait(Math.round((Date.now() - started) / 1000));
981
+ const current = probeWorkspace(ed, run);
982
+ const verdict = verifyVerdict(baseline, current);
983
+ if (verdict.kind === "connected")
984
+ return verdict;
985
+ if (verdict.kind === "not-connected")
986
+ sawNotConnected = true;
987
+ }
988
+ return sawNotConnected ? { kind: "not-connected" } : { kind: "unverified" };
989
+ }
990
+ export async function attemptEditorLaunch(ed, io) {
991
+ // ONE reading answers both halves: the build the workspace holds, and — as the
992
+ // verification's baseline — what was already there before we opened anything. Taking
993
+ // it once means one extra command in the candidate's own activity record, not two.
994
+ const baseline = io.probe(ed);
995
+ const seed = seedVerdict(io.clientVersion, baseline);
996
+ if (seed.kind === "mismatch" && ed.requiresSeedMatch) {
997
+ return { kind: "refused", reason: "seed-mismatch", seed };
998
+ }
999
+ const extension = io.ensureExtension();
1000
+ const logPath = io.launchLogPath();
1001
+ const launched = io.launch(logPath);
1002
+ const verdict = await io.verifyAttached(baseline, launched);
1003
+ const launchLog = io.readLaunchLog(logPath);
1004
+ // Read AFTER the await, never before it: `spawn` reports this on a later tick, so a
1005
+ // check taken at the call site would still be null for a launch that had already
1006
+ // failed. A verdict answered over a workspace says nothing about whether the window
1007
+ // exists, so the error outranks it rather than being folded into it.
1008
+ const error = launched.launchFailure();
1009
+ if (error !== null)
1010
+ return { kind: "launch-failed", seed, extension, error, logPath, launchLog };
1011
+ return { kind: "launched", seed, extension, verdict, logPath, launchLog };
423
1012
  }
424
1013
  /**
425
1014
  * Resolve which editor to open: an explicit --ide flag wins; otherwise prompt.
@@ -748,7 +1337,8 @@ export function browserFallback(hasUrl, ideWasChosen) {
748
1337
  return "browser";
749
1338
  return ideWasChosen ? "unavailable" : "vscode";
750
1339
  }
751
- export async function runConnect(token, ideOpt) {
1340
+ export async function runConnect(token, ideOpt, opts = {}) {
1341
+ const verbose = Boolean(opts.verbose);
752
1342
  const apiBase = resolveApiBase(process.env.LITMUS_API_URL);
753
1343
  setErrorContext({ token, apiBase });
754
1344
  // 1. Authenticate + learn the backend URL. This does NOT mark the candidate
@@ -896,11 +1486,20 @@ export async function runConnect(token, ideOpt) {
896
1486
  // reassigned `ide` for the compiler, so pin the descriptor explicitly rather than
897
1487
  // indexing with a widened key.
898
1488
  const editor = ide === "cursor" ? NATIVE_EDITORS.cursor : NATIVE_EDITORS.vscode;
1489
+ // ENG-2332 — find the editor's CLI before asking what it is. PATH first, then the
1490
+ // places its installer puts it, because "not on PATH" was being reported as "not
1491
+ // installed" on a machine where the binary was sitting in the application bundle the
1492
+ // whole time. Null here means we genuinely could not find a way to drive it.
1493
+ const resolved = resolveEditorCommand(editor);
899
1494
  // ENG-2152 — resolve WHAT THAT COMMAND ACTUALLY IS before anything acts on the choice.
900
1495
  // `bin` is a name on PATH, and on a machine where Cursor is the default editor `code`
901
1496
  // can be Cursor's own CLI. The verdict is taken here, above the Copilot Chat step,
902
1497
  // because that step is the first thing that acts on the candidate's answer.
903
- const identity = identifyEditorCli(editor);
1498
+ //
1499
+ // With nothing resolved there is nothing to probe, and `unverified` is what that means:
1500
+ // it is the absence branch's job to say so, below the mismatch refusal, so a candidate
1501
+ // whose `code` is Cursor is told THAT rather than that their editor is missing.
1502
+ const identity = resolved ? identifyEditorCli(editor, resolved.command) : { kind: "unverified" };
904
1503
  // 5b. Point this machine's Copilot Chat at the Litmus gateway (ENG-1475).
905
1504
  //
906
1505
  // ONLY ON THE NATIVE VS CODE PATH, and that is the whole reason it is here rather
@@ -987,35 +1586,26 @@ export async function runConnect(token, ideOpt) {
987
1586
  // reports success and the candidate is told we readied VS Code's Remote-SSH for them.
988
1587
  //
989
1588
  // Above the ABSENCE check too, and that ordering is what makes this hold on Windows.
990
- // The probe can READ a `code.cmd` (it goes through a shell there), but `hasEditorCli`
991
- // and `launchEditor` still cannot DRIVE one, so a Windows candidate has always landed
992
- // on the manual-instructions branch below a pre-existing gap this ticket does not
993
- // widen and deliberately does not paper over: giving those two a shell without proving
994
- // the spawn works would turn clear instructions into a silent nothing. Checking the
995
- // mismatch first means a Windows candidate whose `code` is Cursor is told so, rather
996
- // than told their editor is not installed.
1589
+ // Every probe and every spawn now goes through `editorSpawnSpec`, so a Windows
1590
+ // `code.cmd` is driven the same way it is read (ENG-2332); before that, the probe could
1591
+ // READ one and the launch could not DRIVE one, so every Windows candidate landed on the
1592
+ // manual-instructions branch. Checking the mismatch first means a Windows candidate
1593
+ // whose `code` is Cursor is told so, rather than told their editor is not installed.
997
1594
  if (identity.kind === "mismatch") {
998
1595
  for (const line of editorMismatchLines(editor, NATIVE_EDITORS[identity.actual], reentry)) {
999
1596
  console.log(line);
1000
1597
  }
1001
1598
  return;
1002
1599
  }
1003
- if (identity.kind === "unverified") {
1004
- // Proceeding as asked see `EditorIdentity`. Said out loud because it is the one line
1005
- // that tells us, when a candidate reports opening the wrong editor, that the probe
1006
- // could not read this machine rather than that it read it wrong.
1007
- console.log(chalk.dim(` (Couldn't confirm that \`${editor.bin}\` is ${editor.label}; opening it as you asked.)`));
1008
- }
1009
- if (!hasEditorCli(editor)) {
1010
- console.log();
1011
- warn(`${editor.label} \`${editor.bin}\` command not found on PATH.`);
1012
- console.log(" Connect manually:");
1013
- console.log(chalk.cyan(` ${editor.bin} --remote ssh-remote+${alias} ${REMOTE_WORKSPACE_DIR}`));
1014
- console.log(` or in ${editor.label}: Remote-SSH → Connect to Host → ${chalk.bold(alias)}.`);
1015
- console.log(chalk.dim(` (Install \`${editor.bin}\` on PATH via ${editor.label}'s "${editor.shellCommand}".)`));
1600
+ if (!resolved) {
1601
+ // ENG-2332 the CLI is not on PATH and not where the installer puts it. Everything
1602
+ // offered here is reachable WITHOUT it; the line this replaced offered
1603
+ // `<bin> --remote …`, i.e. the binary whose absence is the message.
1604
+ for (const line of editorNotFoundLines(editor, alias, conn.browser_ide_url ?? null))
1605
+ console.log(line);
1016
1606
  // The full-quit requirement outlives this degraded path — arguably it matters MORE
1017
- // here, since a candidate typing the command by hand is doing it from a shell that
1018
- // may well be inside the Cursor they still have to quit.
1607
+ // here, since a candidate connecting by hand is doing it from an editor they still
1608
+ // have to quit.
1019
1609
  //
1020
1610
  // ENG-2145: and so does the way back. This used to point at "the note above", which
1021
1611
  // is a back-reference to a notice printed BEFORE a boot wait that can run to two
@@ -1023,27 +1613,130 @@ export async function runConnect(token, ideOpt) {
1023
1613
  // about this very command, which by then has returned. So the route is restated
1024
1614
  // here in full rather than referred to.
1025
1615
  if (ide === "cursor") {
1026
- console.log(chalk.yellow(" Quit Cursor completely before you run that, or your work is not recorded."));
1616
+ console.log(chalk.yellow(" Quit Cursor completely before you connect, or your work is not recorded."));
1027
1617
  for (const line of cursorReentryLines(reentry))
1028
1618
  console.log(line);
1029
1619
  }
1030
1620
  return;
1031
1621
  }
1032
- const extSpinner = ora(`Checking ${editor.label} Remote-SSH extension...`).start();
1033
- const ext = ensureRemoteSshExtension(editor);
1034
- if (ext === "present")
1035
- extSpinner.succeed("Remote-SSH extension ready");
1036
- else if (ext === "installed")
1037
- extSpinner.succeed(`Installed the ${editor.label} Remote-SSH extension`);
1038
- else
1039
- extSpinner.warn("Couldn't auto-install the Remote-SSH extension");
1040
- launchEditor(editor, alias);
1041
- success(`Opening ${editor.label} ${alias}`);
1042
- if (ext === "failed") {
1043
- console.log(chalk.dim(` If the window won't connect, install "Remote - SSH" (${editor.remoteSshExt}) from the Extensions panel, then run litmus connect again.`));
1622
+ if (identity.kind === "unverified") {
1623
+ // Proceeding as asked — see `EditorIdentity`. Said out loud because it is the one line
1624
+ // that tells us, when a candidate reports opening the wrong editor, that the probe
1625
+ // could not read this machine rather than that it read it wrong.
1626
+ //
1627
+ // ENG-2332 — BELOW the absence branch, not above it. With nothing resolved the
1628
+ // identity is `unverified` for the trivial reason that there was nothing to ask, so
1629
+ // printing this first produced "opening it as you asked" immediately followed by
1630
+ // "not found on PATH" — two lines contradicting each other about whether anything was
1631
+ // about to open, which is on the ticket in its own right.
1632
+ console.log(chalk.dim(` (Couldn't confirm that \`${editor.bin}\` is ${editor.label}; opening it as you asked.)`));
1633
+ }
1634
+ if (resolved.source === "install-location") {
1635
+ info(`Using ${editor.label} at ${resolved.command}`);
1636
+ console.log(chalk.dim(` (\`${editor.bin}\` isn't on your PATH. ${editor.label}'s "${editor.shellCommand}" adds it.)`));
1637
+ }
1638
+ // ENG-2332 — the version check BEFORE anything is opened, then the verification after
1639
+ // it. Both, and their order, live in `attemptEditorLaunch`: the guarantee here is a
1640
+ // property of the SEQUENCE, so it is held in one testable unit rather than in the
1641
+ // arrangement of the lines below. Everything this block still owns is output.
1642
+ const probe = sshProbeRunner(alias);
1643
+ // A holder rather than a `let`, because the spinner is created inside a callback and
1644
+ // read after the await: a plain binding would be narrowed to `null` by control flow
1645
+ // that cannot see the closure that assigns it. Each spinner is also started INSIDE the
1646
+ // step it describes — the workspace read can take twenty seconds, and a spinner saying
1647
+ // "Checking the Remote-SSH extension" through it names the wrong step, on the one path
1648
+ // where the next thing the candidate may see is a refusal that installed nothing.
1649
+ const spinners = { verify: null };
1650
+ const readSpinner = ora(`Checking your workspace...`).start();
1651
+ const outcome = await attemptEditorLaunch(editor, {
1652
+ probe: (ed) => {
1653
+ const reading = probeWorkspace(ed, probe);
1654
+ readSpinner.stop();
1655
+ return reading;
1656
+ },
1657
+ clientVersion: resolved.version,
1658
+ ensureExtension: () => {
1659
+ const extSpinner = ora(`Checking ${editor.label} Remote-SSH extension...`).start();
1660
+ const state = ensureRemoteSshExtension(editor, resolved.command);
1661
+ if (state === "present")
1662
+ extSpinner.succeed("Remote-SSH extension ready");
1663
+ else if (state === "installed")
1664
+ extSpinner.succeed(`Installed the ${editor.label} Remote-SSH extension`);
1665
+ else
1666
+ extSpinner.warn("Couldn't auto-install the Remote-SSH extension");
1667
+ return state;
1668
+ },
1669
+ launchLogPath: editorLaunchLogPath,
1670
+ launch: (logPath) => {
1671
+ const launched = launchEditor(editor, resolved.command, alias, logPath);
1672
+ // The launch is not the result, so nothing claims one here. What used to be printed
1673
+ // at this point was `✔ Opening <editor> → <alias>`, which is true of a spawn that is
1674
+ // about to fail, and was the last thing the CLI said in a session where nothing was
1675
+ // ever recorded.
1676
+ spinners.verify = ora(`Opening ${editor.label} — waiting for it to start in your workspace...`).start();
1677
+ return launched;
1678
+ },
1679
+ readLaunchLog: readEditorLaunchLog,
1680
+ verifyAttached: (baseline, launched) => verifyEditorAttached(editor, baseline, probe, (waited) => {
1681
+ if (spinners.verify) {
1682
+ spinners.verify.text = `Opening ${editor.label} — waiting for it to start in your workspace... (${waited}s)`;
1683
+ }
1684
+ }, launched),
1685
+ });
1686
+ if (outcome.kind === "refused") {
1687
+ for (const line of editorSeedMismatchLines(editor, outcome.seed, conn.browser_ide_url ?? null, alias)) {
1688
+ console.log(line);
1689
+ }
1690
+ return;
1691
+ }
1692
+ const launchSpinner = spinners.verify;
1693
+ if (outcome.kind === "launch-failed") {
1694
+ // No window was created, so none of the not-connected copy is true here. See
1695
+ // `editorLaunchFailedLines`.
1696
+ launchSpinner?.fail(`Couldn't start ${editor.label}.`);
1697
+ for (const line of editorLaunchFailedLines(editor, alias, conn.browser_ide_url ?? null, outcome.error, verbose ? null : outcome.launchLog)) {
1698
+ console.log(line);
1699
+ }
1700
+ for (const line of editorLaunchOutputLines(outcome.logPath, outcome.launchLog, verbose, true))
1701
+ console.log(line);
1702
+ return;
1703
+ }
1704
+ if (outcome.verdict.kind === "connected") {
1705
+ launchSpinner?.succeed(`${editor.label} is running in your workspace — your files, terminal and commits are recorded.`);
1706
+ for (const line of editorConnectedLines(editor, alias))
1707
+ console.log(line);
1708
+ }
1709
+ else if (outcome.verdict.kind === "unverified") {
1710
+ // Three-valued on purpose: we could not reach the workspace to ask, which is not the
1711
+ // same as knowing nothing came up, and saying either of the other two here would be a
1712
+ // claim we cannot make. See `verifyVerdict`.
1713
+ launchSpinner?.warn(`Couldn't check whether ${editor.label} attached to your workspace.`);
1714
+ for (const line of editorUnverifiedLines(editor, alias, conn.browser_ide_url ?? null))
1715
+ console.log(line);
1044
1716
  }
1045
1717
  else {
1046
- console.log(chalk.dim(" If a new window doesn't appear, run litmus connect again."));
1718
+ launchSpinner?.fail(`${editor.label} did not start in your workspace.`);
1719
+ // Under --verbose the whole log is printed below, so the copy's own 8-line tail is
1720
+ // withheld rather than repeated; without it that tail is the only view of the output.
1721
+ for (const line of editorNotConnectedLines(editor, alias, conn.browser_ide_url ?? null, outcome.seed, verbose ? null : outcome.launchLog)) {
1722
+ console.log(line);
1723
+ }
1724
+ if (outcome.extension === "failed") {
1725
+ console.log(chalk.dim(` The Remote-SSH extension ("${editor.remoteSshExt}") also failed to install, which would explain this.`));
1726
+ }
1727
+ // The one outcome somebody is walked through, and the log now carries a per-run
1728
+ // suffix — so its PATH is named here whether or not --verbose was asked for.
1729
+ for (const line of editorLaunchOutputLines(outcome.logPath, outcome.launchLog, verbose, true))
1730
+ console.log(line);
1731
+ return;
1732
+ }
1733
+ // The log's PATH is named on every branch a human gets walked through, and `unverified`
1734
+ // is one: it is precisely the branch where we could not reach the workspace at all, so
1735
+ // the editor's own resolver message is the only evidence anybody has — and the name
1736
+ // carries a per-run suffix nobody can guess unaided. `editorLaunchOutputLines` still
1737
+ // withholds a path with nothing behind it.
1738
+ for (const line of editorLaunchOutputLines(outcome.logPath, outcome.launchLog, verbose, outcome.verdict.kind !== "connected")) {
1739
+ console.log(line);
1047
1740
  }
1048
1741
  // ENG-2154. The window we have just opened outlives the session, and what it
1049
1742
  // does when the session ends looks like a failure. This is the only moment