litmus-cli 1.4.28 → 1.4.30
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.
- package/dist/commands/connect.d.ts +360 -14
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +767 -70
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/doctor.d.ts +52 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +76 -7
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +6 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/pause.d.ts +13 -8
- package/dist/commands/pause.d.ts.map +1 -1
- package/dist/commands/pause.js +18 -10
- package/dist/commands/pause.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +6 -2
- package/dist/commands/push.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/api-base.d.ts +80 -0
- package/dist/lib/api-base.d.ts.map +1 -1
- package/dist/lib/api-base.js +83 -0
- package/dist/lib/api-base.js.map +1 -1
- package/dist/lib/editor-binary.d.ts +110 -0
- package/dist/lib/editor-binary.d.ts.map +1 -0
- package/dist/lib/editor-binary.js +72 -0
- package/dist/lib/editor-binary.js.map +1 -0
- package/dist/lib/editor-ide.d.ts +11 -0
- package/dist/lib/editor-ide.d.ts.map +1 -0
- package/dist/lib/editor-ide.js +2 -0
- package/dist/lib/editor-ide.js.map +1 -0
- package/dist/lib/editor-server.d.ts +253 -0
- package/dist/lib/editor-server.d.ts.map +1 -0
- package/dist/lib/editor-server.js +286 -0
- package/dist/lib/editor-server.js.map +1 -0
- package/dist/lib/watcher.js +18 -1
- package/dist/lib/watcher.js.map +1 -1
- package/package.json +1 -1
package/dist/commands/connect.js
CHANGED
|
@@ -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
|
-
|
|
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,16 +290,60 @@ 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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
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 };
|
|
277
345
|
}
|
|
346
|
+
return null;
|
|
278
347
|
}
|
|
279
348
|
/**
|
|
280
349
|
* Map `--help` output to an editor we know, or null.
|
|
@@ -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(
|
|
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(
|
|
393
|
+
const probed = spawnSync(spec.command, spec.args, {
|
|
320
394
|
encoding: "utf8",
|
|
321
|
-
shell:
|
|
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(
|
|
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
|
-
*
|
|
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
|
|
402
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -576,22 +1165,26 @@ export function announceCursorFullQuit(log = console.log, reentry = CURSOR_REENT
|
|
|
576
1165
|
* Map a menu answer to an editor. (ENG-1667 item 3; Cursor added by ENG-1950.)
|
|
577
1166
|
*
|
|
578
1167
|
* Exported only so the DEFAULT is testable. What an empty answer resolves to is the whole
|
|
579
|
-
* of
|
|
1168
|
+
* of that change, and it is one character away from silently reverting.
|
|
580
1169
|
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
1170
|
+
* ENG-2389 — Caroline's call: the two native editors are now 2 and 3 and plain SSH is
|
|
1171
|
+
* last, so the menu reads as the three ways IN (click a link, open your editor, print a
|
|
1172
|
+
* command) rather than as the order the options happened to be built in. That moves two
|
|
1173
|
+
* numbers, which ENG-1950 had deliberately refused to do — an older screenshot or support
|
|
1174
|
+
* answer saying "3" now lands on Cursor rather than on the ssh command. The cost is
|
|
1175
|
+
* bounded in a way it would not have been then: both 3 and 4 still open a workspace, the
|
|
1176
|
+
* label is on screen beside the number at the moment of choosing, and `--ide <name>` is
|
|
1177
|
+
* the spelling anything written down should be using. Numbers are cosmetic; what a number
|
|
1178
|
+
* MEANS is not, so the mapping and `ideMenuLines` are pinned to each other by test.
|
|
586
1179
|
*/
|
|
587
1180
|
export function ideForChoice(answer) {
|
|
588
1181
|
const a = answer.trim();
|
|
589
1182
|
if (a === "2")
|
|
590
1183
|
return "vscode";
|
|
591
1184
|
if (a === "3")
|
|
592
|
-
return "ssh";
|
|
593
|
-
if (a === "4")
|
|
594
1185
|
return "cursor";
|
|
1186
|
+
if (a === "4")
|
|
1187
|
+
return "ssh";
|
|
595
1188
|
return "browser";
|
|
596
1189
|
}
|
|
597
1190
|
/**
|
|
@@ -603,10 +1196,10 @@ export function ideMenuLines() {
|
|
|
603
1196
|
return [
|
|
604
1197
|
` ${chalk.bold("1")}) Browser IDE — nothing to install locally ${chalk.dim("[default]")}`,
|
|
605
1198
|
` ${chalk.bold("2")}) VS Code — native, via Remote-SSH`,
|
|
606
|
-
|
|
607
|
-
//
|
|
608
|
-
|
|
609
|
-
` ${chalk.bold("4")})
|
|
1199
|
+
// ENG-2389. Cursor sits beside VS Code, where it belongs by kinship, and plain SSH is
|
|
1200
|
+
// last — see `ideForChoice` for what that renumbering costs and why it is bounded.
|
|
1201
|
+
` ${chalk.bold("3")}) Cursor — native, via Remote-SSH ${chalk.dim("(quit Cursor first)")}`,
|
|
1202
|
+
` ${chalk.bold("4")}) SSH — just print the command for your terminal`,
|
|
610
1203
|
];
|
|
611
1204
|
}
|
|
612
1205
|
/**
|
|
@@ -748,7 +1341,8 @@ export function browserFallback(hasUrl, ideWasChosen) {
|
|
|
748
1341
|
return "browser";
|
|
749
1342
|
return ideWasChosen ? "unavailable" : "vscode";
|
|
750
1343
|
}
|
|
751
|
-
export async function runConnect(token, ideOpt) {
|
|
1344
|
+
export async function runConnect(token, ideOpt, opts = {}) {
|
|
1345
|
+
const verbose = Boolean(opts.verbose);
|
|
752
1346
|
const apiBase = resolveApiBase(process.env.LITMUS_API_URL);
|
|
753
1347
|
setErrorContext({ token, apiBase });
|
|
754
1348
|
// 1. Authenticate + learn the backend URL. This does NOT mark the candidate
|
|
@@ -896,11 +1490,20 @@ export async function runConnect(token, ideOpt) {
|
|
|
896
1490
|
// reassigned `ide` for the compiler, so pin the descriptor explicitly rather than
|
|
897
1491
|
// indexing with a widened key.
|
|
898
1492
|
const editor = ide === "cursor" ? NATIVE_EDITORS.cursor : NATIVE_EDITORS.vscode;
|
|
1493
|
+
// ENG-2332 — find the editor's CLI before asking what it is. PATH first, then the
|
|
1494
|
+
// places its installer puts it, because "not on PATH" was being reported as "not
|
|
1495
|
+
// installed" on a machine where the binary was sitting in the application bundle the
|
|
1496
|
+
// whole time. Null here means we genuinely could not find a way to drive it.
|
|
1497
|
+
const resolved = resolveEditorCommand(editor);
|
|
899
1498
|
// ENG-2152 — resolve WHAT THAT COMMAND ACTUALLY IS before anything acts on the choice.
|
|
900
1499
|
// `bin` is a name on PATH, and on a machine where Cursor is the default editor `code`
|
|
901
1500
|
// can be Cursor's own CLI. The verdict is taken here, above the Copilot Chat step,
|
|
902
1501
|
// because that step is the first thing that acts on the candidate's answer.
|
|
903
|
-
|
|
1502
|
+
//
|
|
1503
|
+
// With nothing resolved there is nothing to probe, and `unverified` is what that means:
|
|
1504
|
+
// it is the absence branch's job to say so, below the mismatch refusal, so a candidate
|
|
1505
|
+
// whose `code` is Cursor is told THAT rather than that their editor is missing.
|
|
1506
|
+
const identity = resolved ? identifyEditorCli(editor, resolved.command) : { kind: "unverified" };
|
|
904
1507
|
// 5b. Point this machine's Copilot Chat at the Litmus gateway (ENG-1475).
|
|
905
1508
|
//
|
|
906
1509
|
// ONLY ON THE NATIVE VS CODE PATH, and that is the whole reason it is here rather
|
|
@@ -987,35 +1590,26 @@ export async function runConnect(token, ideOpt) {
|
|
|
987
1590
|
// reports success and the candidate is told we readied VS Code's Remote-SSH for them.
|
|
988
1591
|
//
|
|
989
1592
|
// Above the ABSENCE check too, and that ordering is what makes this hold on Windows.
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
// mismatch first means a Windows candidate whose `code` is Cursor is told so, rather
|
|
996
|
-
// than told their editor is not installed.
|
|
1593
|
+
// Every probe and every spawn now goes through `editorSpawnSpec`, so a Windows
|
|
1594
|
+
// `code.cmd` is driven the same way it is read (ENG-2332); before that, the probe could
|
|
1595
|
+
// READ one and the launch could not DRIVE one, so every Windows candidate landed on the
|
|
1596
|
+
// manual-instructions branch. Checking the mismatch first means a Windows candidate
|
|
1597
|
+
// whose `code` is Cursor is told so, rather than told their editor is not installed.
|
|
997
1598
|
if (identity.kind === "mismatch") {
|
|
998
1599
|
for (const line of editorMismatchLines(editor, NATIVE_EDITORS[identity.actual], reentry)) {
|
|
999
1600
|
console.log(line);
|
|
1000
1601
|
}
|
|
1001
1602
|
return;
|
|
1002
1603
|
}
|
|
1003
|
-
if (
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1007
|
-
|
|
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}".)`));
|
|
1604
|
+
if (!resolved) {
|
|
1605
|
+
// ENG-2332 — the CLI is not on PATH and not where the installer puts it. Everything
|
|
1606
|
+
// offered here is reachable WITHOUT it; the line this replaced offered
|
|
1607
|
+
// `<bin> --remote …`, i.e. the binary whose absence is the message.
|
|
1608
|
+
for (const line of editorNotFoundLines(editor, alias, conn.browser_ide_url ?? null))
|
|
1609
|
+
console.log(line);
|
|
1016
1610
|
// The full-quit requirement outlives this degraded path — arguably it matters MORE
|
|
1017
|
-
// here, since a candidate
|
|
1018
|
-
//
|
|
1611
|
+
// here, since a candidate connecting by hand is doing it from an editor they still
|
|
1612
|
+
// have to quit.
|
|
1019
1613
|
//
|
|
1020
1614
|
// ENG-2145: and so does the way back. This used to point at "the note above", which
|
|
1021
1615
|
// is a back-reference to a notice printed BEFORE a boot wait that can run to two
|
|
@@ -1023,27 +1617,130 @@ export async function runConnect(token, ideOpt) {
|
|
|
1023
1617
|
// about this very command, which by then has returned. So the route is restated
|
|
1024
1618
|
// here in full rather than referred to.
|
|
1025
1619
|
if (ide === "cursor") {
|
|
1026
|
-
console.log(chalk.yellow(" Quit Cursor completely before you
|
|
1620
|
+
console.log(chalk.yellow(" Quit Cursor completely before you connect, or your work is not recorded."));
|
|
1027
1621
|
for (const line of cursorReentryLines(reentry))
|
|
1028
1622
|
console.log(line);
|
|
1029
1623
|
}
|
|
1030
1624
|
return;
|
|
1031
1625
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1626
|
+
if (identity.kind === "unverified") {
|
|
1627
|
+
// Proceeding as asked — see `EditorIdentity`. Said out loud because it is the one line
|
|
1628
|
+
// that tells us, when a candidate reports opening the wrong editor, that the probe
|
|
1629
|
+
// could not read this machine rather than that it read it wrong.
|
|
1630
|
+
//
|
|
1631
|
+
// ENG-2332 — BELOW the absence branch, not above it. With nothing resolved the
|
|
1632
|
+
// identity is `unverified` for the trivial reason that there was nothing to ask, so
|
|
1633
|
+
// printing this first produced "opening it as you asked" immediately followed by
|
|
1634
|
+
// "not found on PATH" — two lines contradicting each other about whether anything was
|
|
1635
|
+
// about to open, which is on the ticket in its own right.
|
|
1636
|
+
console.log(chalk.dim(` (Couldn't confirm that \`${editor.bin}\` is ${editor.label}; opening it as you asked.)`));
|
|
1637
|
+
}
|
|
1638
|
+
if (resolved.source === "install-location") {
|
|
1639
|
+
info(`Using ${editor.label} at ${resolved.command}`);
|
|
1640
|
+
console.log(chalk.dim(` (\`${editor.bin}\` isn't on your PATH. ${editor.label}'s "${editor.shellCommand}" adds it.)`));
|
|
1641
|
+
}
|
|
1642
|
+
// ENG-2332 — the version check BEFORE anything is opened, then the verification after
|
|
1643
|
+
// it. Both, and their order, live in `attemptEditorLaunch`: the guarantee here is a
|
|
1644
|
+
// property of the SEQUENCE, so it is held in one testable unit rather than in the
|
|
1645
|
+
// arrangement of the lines below. Everything this block still owns is output.
|
|
1646
|
+
const probe = sshProbeRunner(alias);
|
|
1647
|
+
// A holder rather than a `let`, because the spinner is created inside a callback and
|
|
1648
|
+
// read after the await: a plain binding would be narrowed to `null` by control flow
|
|
1649
|
+
// that cannot see the closure that assigns it. Each spinner is also started INSIDE the
|
|
1650
|
+
// step it describes — the workspace read can take twenty seconds, and a spinner saying
|
|
1651
|
+
// "Checking the Remote-SSH extension" through it names the wrong step, on the one path
|
|
1652
|
+
// where the next thing the candidate may see is a refusal that installed nothing.
|
|
1653
|
+
const spinners = { verify: null };
|
|
1654
|
+
const readSpinner = ora(`Checking your workspace...`).start();
|
|
1655
|
+
const outcome = await attemptEditorLaunch(editor, {
|
|
1656
|
+
probe: (ed) => {
|
|
1657
|
+
const reading = probeWorkspace(ed, probe);
|
|
1658
|
+
readSpinner.stop();
|
|
1659
|
+
return reading;
|
|
1660
|
+
},
|
|
1661
|
+
clientVersion: resolved.version,
|
|
1662
|
+
ensureExtension: () => {
|
|
1663
|
+
const extSpinner = ora(`Checking ${editor.label} Remote-SSH extension...`).start();
|
|
1664
|
+
const state = ensureRemoteSshExtension(editor, resolved.command);
|
|
1665
|
+
if (state === "present")
|
|
1666
|
+
extSpinner.succeed("Remote-SSH extension ready");
|
|
1667
|
+
else if (state === "installed")
|
|
1668
|
+
extSpinner.succeed(`Installed the ${editor.label} Remote-SSH extension`);
|
|
1669
|
+
else
|
|
1670
|
+
extSpinner.warn("Couldn't auto-install the Remote-SSH extension");
|
|
1671
|
+
return state;
|
|
1672
|
+
},
|
|
1673
|
+
launchLogPath: editorLaunchLogPath,
|
|
1674
|
+
launch: (logPath) => {
|
|
1675
|
+
const launched = launchEditor(editor, resolved.command, alias, logPath);
|
|
1676
|
+
// The launch is not the result, so nothing claims one here. What used to be printed
|
|
1677
|
+
// at this point was `✔ Opening <editor> → <alias>`, which is true of a spawn that is
|
|
1678
|
+
// about to fail, and was the last thing the CLI said in a session where nothing was
|
|
1679
|
+
// ever recorded.
|
|
1680
|
+
spinners.verify = ora(`Opening ${editor.label} — waiting for it to start in your workspace...`).start();
|
|
1681
|
+
return launched;
|
|
1682
|
+
},
|
|
1683
|
+
readLaunchLog: readEditorLaunchLog,
|
|
1684
|
+
verifyAttached: (baseline, launched) => verifyEditorAttached(editor, baseline, probe, (waited) => {
|
|
1685
|
+
if (spinners.verify) {
|
|
1686
|
+
spinners.verify.text = `Opening ${editor.label} — waiting for it to start in your workspace... (${waited}s)`;
|
|
1687
|
+
}
|
|
1688
|
+
}, launched),
|
|
1689
|
+
});
|
|
1690
|
+
if (outcome.kind === "refused") {
|
|
1691
|
+
for (const line of editorSeedMismatchLines(editor, outcome.seed, conn.browser_ide_url ?? null, alias)) {
|
|
1692
|
+
console.log(line);
|
|
1693
|
+
}
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
const launchSpinner = spinners.verify;
|
|
1697
|
+
if (outcome.kind === "launch-failed") {
|
|
1698
|
+
// No window was created, so none of the not-connected copy is true here. See
|
|
1699
|
+
// `editorLaunchFailedLines`.
|
|
1700
|
+
launchSpinner?.fail(`Couldn't start ${editor.label}.`);
|
|
1701
|
+
for (const line of editorLaunchFailedLines(editor, alias, conn.browser_ide_url ?? null, outcome.error, verbose ? null : outcome.launchLog)) {
|
|
1702
|
+
console.log(line);
|
|
1703
|
+
}
|
|
1704
|
+
for (const line of editorLaunchOutputLines(outcome.logPath, outcome.launchLog, verbose, true))
|
|
1705
|
+
console.log(line);
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
if (outcome.verdict.kind === "connected") {
|
|
1709
|
+
launchSpinner?.succeed(`${editor.label} is running in your workspace — your files, terminal and commits are recorded.`);
|
|
1710
|
+
for (const line of editorConnectedLines(editor, alias))
|
|
1711
|
+
console.log(line);
|
|
1712
|
+
}
|
|
1713
|
+
else if (outcome.verdict.kind === "unverified") {
|
|
1714
|
+
// Three-valued on purpose: we could not reach the workspace to ask, which is not the
|
|
1715
|
+
// same as knowing nothing came up, and saying either of the other two here would be a
|
|
1716
|
+
// claim we cannot make. See `verifyVerdict`.
|
|
1717
|
+
launchSpinner?.warn(`Couldn't check whether ${editor.label} attached to your workspace.`);
|
|
1718
|
+
for (const line of editorUnverifiedLines(editor, alias, conn.browser_ide_url ?? null))
|
|
1719
|
+
console.log(line);
|
|
1044
1720
|
}
|
|
1045
1721
|
else {
|
|
1046
|
-
|
|
1722
|
+
launchSpinner?.fail(`${editor.label} did not start in your workspace.`);
|
|
1723
|
+
// Under --verbose the whole log is printed below, so the copy's own 8-line tail is
|
|
1724
|
+
// withheld rather than repeated; without it that tail is the only view of the output.
|
|
1725
|
+
for (const line of editorNotConnectedLines(editor, alias, conn.browser_ide_url ?? null, outcome.seed, verbose ? null : outcome.launchLog)) {
|
|
1726
|
+
console.log(line);
|
|
1727
|
+
}
|
|
1728
|
+
if (outcome.extension === "failed") {
|
|
1729
|
+
console.log(chalk.dim(` The Remote-SSH extension ("${editor.remoteSshExt}") also failed to install, which would explain this.`));
|
|
1730
|
+
}
|
|
1731
|
+
// The one outcome somebody is walked through, and the log now carries a per-run
|
|
1732
|
+
// suffix — so its PATH is named here whether or not --verbose was asked for.
|
|
1733
|
+
for (const line of editorLaunchOutputLines(outcome.logPath, outcome.launchLog, verbose, true))
|
|
1734
|
+
console.log(line);
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
// The log's PATH is named on every branch a human gets walked through, and `unverified`
|
|
1738
|
+
// is one: it is precisely the branch where we could not reach the workspace at all, so
|
|
1739
|
+
// the editor's own resolver message is the only evidence anybody has — and the name
|
|
1740
|
+
// carries a per-run suffix nobody can guess unaided. `editorLaunchOutputLines` still
|
|
1741
|
+
// withholds a path with nothing behind it.
|
|
1742
|
+
for (const line of editorLaunchOutputLines(outcome.logPath, outcome.launchLog, verbose, outcome.verdict.kind !== "connected")) {
|
|
1743
|
+
console.log(line);
|
|
1047
1744
|
}
|
|
1048
1745
|
// ENG-2154. The window we have just opened outlives the session, and what it
|
|
1049
1746
|
// does when the session ends looks like a failure. This is the only moment
|