@quickchatai/quickreview 1.1.0 → 1.2.0
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/README.md +35 -19
- package/config.example.toml +41 -9
- package/dist/extensions/cli/login-route.js +5 -0
- package/dist/extensions/cli/login-route.js.map +1 -0
- package/dist/extensions/login-route.js +180 -0
- package/dist/extensions/login-route.js.map +1 -0
- package/dist/extensions/onboarding.js +29 -6
- package/dist/extensions/onboarding.js.map +1 -1
- package/dist/src/ciretry.js +2 -1
- package/dist/src/ciretry.js.map +1 -1
- package/dist/src/cli.js +43 -12
- package/dist/src/cli.js.map +1 -1
- package/dist/src/cliops.js +3 -2
- package/dist/src/cliops.js.map +1 -1
- package/dist/src/commands.js +25 -2
- package/dist/src/commands.js.map +1 -1
- package/dist/src/config.js +16 -2
- package/dist/src/config.js.map +1 -1
- package/dist/src/daemon.js +24 -8
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/debugroundpi.js +5 -1
- package/dist/src/debugroundpi.js.map +1 -1
- package/dist/src/dispatch.js +5 -4
- package/dist/src/dispatch.js.map +1 -1
- package/dist/src/engine/debugpi.js +17 -5
- package/dist/src/engine/debugpi.js.map +1 -1
- package/dist/src/execboundary.js +10 -3
- package/dist/src/execboundary.js.map +1 -1
- package/dist/src/github.js +51 -16
- package/dist/src/github.js.map +1 -1
- package/dist/src/githubtoken.js +346 -0
- package/dist/src/githubtoken.js.map +1 -0
- package/dist/src/loginhint.js +8 -3
- package/dist/src/loginhint.js.map +1 -1
- package/dist/src/loginrefresh/pirefresh.js +3 -0
- package/dist/src/loginrefresh/pirefresh.js.map +1 -1
- package/dist/src/npmverbs.js +4 -2
- package/dist/src/npmverbs.js.map +1 -1
- package/dist/src/onboard/choices.js +29 -1
- package/dist/src/onboard/choices.js.map +1 -1
- package/dist/src/onboard/engine.js +8 -23
- package/dist/src/onboard/engine.js.map +1 -1
- package/dist/src/onboard/launch.js +12 -20
- package/dist/src/onboard/launch.js.map +1 -1
- package/dist/src/onboard/pipin.js +22 -0
- package/dist/src/onboard/pipin.js.map +1 -0
- package/dist/src/onboard/stepforward.js +41 -0
- package/dist/src/onboard/stepforward.js.map +1 -0
- package/dist/src/onboard/steps/appslug.js +4 -1
- package/dist/src/onboard/steps/appslug.js.map +1 -1
- package/dist/src/onboard/steps/compose.js +29 -0
- package/dist/src/onboard/steps/compose.js.map +1 -1
- package/dist/src/onboard/steps/configfill.js +102 -32
- package/dist/src/onboard/steps/configfill.js.map +1 -1
- package/dist/src/onboard/steps/githubaccount.js +595 -0
- package/dist/src/onboard/steps/githubaccount.js.map +1 -0
- package/dist/src/onboard/steps/githubapp.js +65 -7
- package/dist/src/onboard/steps/githubapp.js.map +1 -1
- package/dist/src/onboard/steps/golive.js +2 -0
- package/dist/src/onboard/steps/golive.js.map +1 -1
- package/dist/src/onboard/steps/index.js +11 -0
- package/dist/src/onboard/steps/index.js.map +1 -1
- package/dist/src/onboard/steps/ingress.js +80 -25
- package/dist/src/onboard/steps/ingress.js.map +1 -1
- package/dist/src/onboard/steps/llmauth.js +103 -30
- package/dist/src/onboard/steps/llmauth.js.map +1 -1
- package/dist/src/onboard/steps/memory.js +53 -1
- package/dist/src/onboard/steps/memory.js.map +1 -1
- package/dist/src/onboard/steps/proof.js +366 -56
- package/dist/src/onboard/steps/proof.js.map +1 -1
- package/dist/src/onboard/steps/topology.js +6 -6
- package/dist/src/onboard/steps/topology.js.map +1 -1
- package/dist/src/onboard/steps/transport.js +9 -1
- package/dist/src/onboard/steps/transport.js.map +1 -1
- package/dist/src/onboard/steps/tuiroute.js +102 -0
- package/dist/src/onboard/steps/tuiroute.js.map +1 -0
- package/dist/src/onboard/steps/verifyup.js +17 -1
- package/dist/src/onboard/steps/verifyup.js.map +1 -1
- package/dist/src/onboard/toolnames.js +30 -0
- package/dist/src/onboard/toolnames.js.map +1 -0
- package/dist/src/operatorwrite.js +23 -0
- package/dist/src/operatorwrite.js.map +1 -0
- package/dist/src/outcomes.js +4 -1
- package/dist/src/outcomes.js.map +1 -1
- package/dist/src/posting-journal.js +2 -1
- package/dist/src/posting-journal.js.map +1 -1
- package/dist/src/posting-outbox.js +6 -2
- package/dist/src/posting-outbox.js.map +1 -1
- package/dist/src/posting-receipt.js +14 -1
- package/dist/src/posting-receipt.js.map +1 -1
- package/dist/src/recheck.js +2 -1
- package/dist/src/recheck.js.map +1 -1
- package/dist/src/reconcile.js +22 -2
- package/dist/src/reconcile.js.map +1 -1
- package/dist/src/reviewhistory.js +32 -6
- package/dist/src/reviewhistory.js.map +1 -1
- package/dist/src/reviewnow.js +45 -0
- package/dist/src/reviewnow.js.map +1 -0
- package/dist/src/state.js.map +1 -1
- package/dist/src/statemachine.js +4 -1
- package/dist/src/statemachine.js.map +1 -1
- package/dist/src/takeoverpi.js +13 -0
- package/dist/src/takeoverpi.js.map +1 -1
- package/dist/src/tui/app.js +49 -0
- package/dist/src/tui/app.js.map +1 -1
- package/dist/src/tui/board.js +88 -12
- package/dist/src/tui/board.js.map +1 -1
- package/dist/src/tui/cmdbar.js +4 -1
- package/dist/src/tui/cmdbar.js.map +1 -1
- package/dist/src/tui/companion.js +74 -10
- package/dist/src/tui/companion.js.map +1 -1
- package/dist/src/tui/connect.js +7 -1
- package/dist/src/tui/connect.js.map +1 -1
- package/dist/src/tui/filters.js +5 -2
- package/dist/src/tui/filters.js.map +1 -1
- package/dist/src/tui/ghread.js +61 -5
- package/dist/src/tui/ghread.js.map +1 -1
- package/dist/src/tui/index.js +106 -20
- package/dist/src/tui/index.js.map +1 -1
- package/dist/src/tui/memforward.js +39 -2
- package/dist/src/tui/memforward.js.map +1 -1
- package/dist/src/tui/model.js +25 -0
- package/dist/src/tui/model.js.map +1 -1
- package/dist/src/tui/prpause.js +7 -0
- package/dist/src/tui/prpause.js.map +1 -1
- package/dist/src/tui/remote.js +7 -1
- package/dist/src/tui/remote.js.map +1 -1
- package/dist/src/tui/route.js +143 -0
- package/dist/src/tui/route.js.map +1 -0
- package/dist/src/tui/setupstate.js +29 -0
- package/dist/src/tui/setupstate.js.map +1 -0
- package/dist/src/tui/sshtriage.js +102 -5
- package/dist/src/tui/sshtriage.js.map +1 -1
- package/dist/src/tui/theme.js +2 -1
- package/dist/src/tui/theme.js.map +1 -1
- package/dist/src/tui/unwedge.js +2 -12
- package/dist/src/tui/unwedge.js.map +1 -1
- package/dist/src/verdicts.js +17 -5
- package/dist/src/verdicts.js.map +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/version.js.map +1 -1
- package/dist/src/webhook.js +12 -1
- package/dist/src/webhook.js.map +1 -1
- package/dist/src/wire.js +28 -6
- package/dist/src/wire.js.map +1 -1
- package/dist/src/workerworld.js +7 -1
- package/dist/src/workerworld.js.map +1 -1
- package/docker/companion/entrypoint.sh +20 -1
- package/docker/entrypoint.sh +17 -0
- package/extensions/cli/login-route.ts +10 -0
- package/extensions/login-route.ts +220 -0
- package/extensions/onboarding.ts +35 -6
- package/package.json +2 -2
- package/prompts/onboarding.md +57 -18
- package/scripts/mint-token.sh +25 -4
- package/src/ciretry.ts +2 -1
- package/src/cli.ts +49 -14
- package/src/cliops.ts +3 -2
- package/src/commands.ts +28 -2
- package/src/config.ts +24 -4
- package/src/daemon.ts +32 -7
- package/src/debugroundpi.ts +11 -1
- package/src/dispatch.ts +5 -4
- package/src/engine/debugpi.ts +17 -5
- package/src/execboundary.ts +16 -4
- package/src/github.ts +57 -15
- package/src/githubtoken.ts +435 -0
- package/src/loginhint.ts +8 -3
- package/src/loginrefresh/pirefresh.ts +3 -0
- package/src/npmverbs.ts +4 -2
- package/src/onboard/choices.ts +34 -1
- package/src/onboard/engine.ts +22 -3
- package/src/onboard/launch.ts +17 -29
- package/src/onboard/pipin.ts +31 -0
- package/src/onboard/stepforward.ts +61 -0
- package/src/onboard/steps/appslug.ts +4 -1
- package/src/onboard/steps/compose.ts +31 -0
- package/src/onboard/steps/configfill.ts +121 -33
- package/src/onboard/steps/githubaccount.ts +716 -0
- package/src/onboard/steps/githubapp.ts +92 -8
- package/src/onboard/steps/golive.ts +2 -0
- package/src/onboard/steps/index.ts +11 -0
- package/src/onboard/steps/ingress.ts +98 -31
- package/src/onboard/steps/llmauth.ts +114 -29
- package/src/onboard/steps/memory.ts +58 -1
- package/src/onboard/steps/proof.ts +473 -55
- package/src/onboard/steps/topology.ts +7 -6
- package/src/onboard/steps/transport.ts +15 -0
- package/src/onboard/steps/tuiroute.ts +119 -0
- package/src/onboard/steps/verifyup.ts +20 -1
- package/src/onboard/toolnames.ts +34 -0
- package/src/operatorwrite.ts +25 -0
- package/src/outcomes.ts +8 -1
- package/src/posting-journal.ts +6 -1
- package/src/posting-outbox.ts +10 -2
- package/src/posting-receipt.ts +17 -0
- package/src/recheck.ts +2 -1
- package/src/reconcile.ts +29 -3
- package/src/reviewhistory.ts +20 -6
- package/src/reviewnow.ts +76 -0
- package/src/state.ts +18 -0
- package/src/statemachine.ts +4 -1
- package/src/takeoverpi.ts +13 -0
- package/src/tui/app.ts +47 -0
- package/src/tui/board.ts +111 -14
- package/src/tui/cmdbar.ts +4 -1
- package/src/tui/companion.ts +78 -10
- package/src/tui/connect.ts +7 -1
- package/src/tui/filters.ts +7 -3
- package/src/tui/ghread.ts +74 -7
- package/src/tui/index.ts +114 -23
- package/src/tui/memforward.ts +52 -3
- package/src/tui/model.ts +36 -2
- package/src/tui/prpause.ts +11 -2
- package/src/tui/remote.ts +9 -1
- package/src/tui/route.ts +173 -0
- package/src/tui/setupstate.ts +52 -0
- package/src/tui/sshtriage.ts +127 -6
- package/src/tui/theme.ts +2 -1
- package/src/tui/unwedge.ts +2 -14
- package/src/verdicts.ts +23 -5
- package/src/version.ts +1 -1
- package/src/webhook.ts +10 -1
- package/src/wire.ts +36 -7
- package/src/workerworld.ts +7 -1
package/src/tui/companion.ts
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
stateHome,
|
|
42
42
|
type InstallEnv,
|
|
43
43
|
} from "../install.js";
|
|
44
|
-
import type
|
|
44
|
+
import { COMPANION_IMAGE, type CompanionConfig } from "../config.js";
|
|
45
45
|
import { writeAtomic } from "../channelfs.js";
|
|
46
46
|
import { appStamp } from "../appstamp.js";
|
|
47
47
|
import { TOKEN_FILE } from "./ghread.js";
|
|
@@ -453,6 +453,15 @@ export function companionInjectDir(cfg: Pick<CompanionConfig, "repo_dir" | "sess
|
|
|
453
453
|
* `qcr scan` the companion runs) knows it is inside the companion container. */
|
|
454
454
|
export const IN_COMPANION_ENV = "QCR_IN_COMPANION";
|
|
455
455
|
|
|
456
|
+
/** The published release image's repository (src/onboard/steps/compose.ts
|
|
457
|
+
* RELEASE_IMAGE_REPO): a tag under it is pulled, never built locally. */
|
|
458
|
+
const RELEASE_IMAGE_PREFIX = "ghcr.io/quickchatai/quickreview:";
|
|
459
|
+
|
|
460
|
+
/** Is `image` a published release tag (pulled, never built over)? */
|
|
461
|
+
export function isReleaseImage(image: string): boolean {
|
|
462
|
+
return image.startsWith(RELEASE_IMAGE_PREFIX);
|
|
463
|
+
}
|
|
464
|
+
|
|
456
465
|
/** The release image's mode word for the companion container
|
|
457
466
|
* (docker/entrypoint.sh): provision pi, start the tmux server, stay alive. */
|
|
458
467
|
export const COMPANION_MODE = "companion";
|
|
@@ -560,6 +569,23 @@ export class CompanionLifecycle {
|
|
|
560
569
|
* companion's tmux server. A field so tests need not wait it out. */
|
|
561
570
|
wrapperStartWaitMs = WRAPPER_START_WAIT_MS;
|
|
562
571
|
|
|
572
|
+
/** An image already on this machine to run instead of BUILDING the default
|
|
573
|
+
* one: the release image a compose box pulled (`ghcr.io/…:<QCR_VERSION>`,
|
|
574
|
+
* the same docker/Dockerfile CI built). Consulted only on a cold create,
|
|
575
|
+
* only while `[companion] image` is the shipped default, and only when no
|
|
576
|
+
* local build of that default exists — so an explicit image and a
|
|
577
|
+
* contributor's `qcr companion rebuild` both keep winning. */
|
|
578
|
+
preferredImage: string | undefined;
|
|
579
|
+
|
|
580
|
+
/** The image this instance runs: `[companion] image`, until a cold create
|
|
581
|
+
* adopts `preferredImage`. */
|
|
582
|
+
private imageInUse: string | undefined;
|
|
583
|
+
|
|
584
|
+
/** The image a create runs (and a build tags). */
|
|
585
|
+
get image(): string {
|
|
586
|
+
return this.imageInUse ?? this.cfg.image;
|
|
587
|
+
}
|
|
588
|
+
|
|
563
589
|
/** The shared socket in `tmux-socket` mode, else undefined. */
|
|
564
590
|
get socketPath(): string | undefined {
|
|
565
591
|
return this.mode === "tmux-socket" ? tmuxSocketPath(this.cfg) : undefined;
|
|
@@ -620,7 +646,7 @@ export class CompanionLifecycle {
|
|
|
620
646
|
// The agent is reached by its mounted socket; no key is in the container.
|
|
621
647
|
if (this.sshSources().agent !== undefined) argv.push("--env", `SSH_AUTH_SOCK=${SSH.agent}`);
|
|
622
648
|
// The release image, in its companion mode (docker/entrypoint.sh).
|
|
623
|
-
argv.push(this.
|
|
649
|
+
argv.push(this.image, COMPANION_MODE);
|
|
624
650
|
return argv;
|
|
625
651
|
}
|
|
626
652
|
|
|
@@ -672,6 +698,13 @@ export class CompanionLifecycle {
|
|
|
672
698
|
"--theme",
|
|
673
699
|
`${ext}/muted-theme.json`,
|
|
674
700
|
"--extension",
|
|
701
|
+
// The login route (extensions/login-route.ts), before the inject bridge so
|
|
702
|
+
// the day-open delivery meets a routed model: a companion whose model's
|
|
703
|
+
// provider has no login runs the same model on the other OpenAI login
|
|
704
|
+
// when that one exists, and a companion with no usable login at all says
|
|
705
|
+
// which logins work and leaves `/login` on its input line.
|
|
706
|
+
`${ext}/cli/login-route.ts`,
|
|
707
|
+
"--extension",
|
|
675
708
|
// Day-open channel: the daemon's inject bridge, unmodified. It
|
|
676
709
|
// drains its directory on session_start before it starts watching, so
|
|
677
710
|
// the rollover can queue the opening message before pi is spawned and
|
|
@@ -926,10 +959,27 @@ export class CompanionLifecycle {
|
|
|
926
959
|
|
|
927
960
|
/** True when the companion image exists locally. */
|
|
928
961
|
async imageExists(): Promise<boolean> {
|
|
929
|
-
const res = await this.docker(["image", "inspect", this.
|
|
962
|
+
const res = await this.docker(["image", "inspect", this.image]);
|
|
930
963
|
return res.ok;
|
|
931
964
|
}
|
|
932
965
|
|
|
966
|
+
/**
|
|
967
|
+
* Before a cold create builds the default image: adopt the preferred
|
|
968
|
+
* (already-pulled release) image when the default is not built here and the
|
|
969
|
+
* preferred one is. True when the image to run now exists.
|
|
970
|
+
*/
|
|
971
|
+
private async adoptPreferredImage(onProgress?: (line: string) => void): Promise<boolean> {
|
|
972
|
+
if (await this.imageExists()) return true;
|
|
973
|
+
const preferred = this.preferredImage;
|
|
974
|
+
if (preferred === undefined || preferred === "" || this.cfg.image !== COMPANION_IMAGE)
|
|
975
|
+
return false;
|
|
976
|
+
const res = await this.docker(["image", "inspect", preferred]);
|
|
977
|
+
if (!res.ok) return false;
|
|
978
|
+
this.imageInUse = preferred;
|
|
979
|
+
onProgress?.(`using the release image ${preferred}, already pulled — no local build`);
|
|
980
|
+
return true;
|
|
981
|
+
}
|
|
982
|
+
|
|
933
983
|
/** Remove the container, running or not. Volumes (pihome: OAuth, the
|
|
934
984
|
* conversation) survive — this replaces the container, never its memory. */
|
|
935
985
|
async removeContainer(): Promise<void> {
|
|
@@ -945,9 +995,22 @@ export class CompanionLifecycle {
|
|
|
945
995
|
if (!res.ok) throw new Error(`docker rm ${this.cfg.container_name} failed: ${errText(res)}`);
|
|
946
996
|
}
|
|
947
997
|
|
|
998
|
+
/** `docker pull` of a configured release image the machine does not have. */
|
|
999
|
+
private async pullImage(onProgress?: (line: string) => void): Promise<void> {
|
|
1000
|
+
onProgress?.(`pulling ${this.image} (first run — a few minutes)…`);
|
|
1001
|
+
const res = await this.docker(["pull", this.image], { timeoutMs: 1_800_000 });
|
|
1002
|
+
if (!res.ok) throw new Error(`docker pull ${this.image} failed: ${errText(res)}`);
|
|
1003
|
+
onProgress?.(`${this.image} pulled`);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
948
1006
|
/** Throws when the image cannot be built (`imageBuildProblem`) — asked
|
|
949
1007
|
* before anything is removed for a rebuild. */
|
|
950
1008
|
checkBuildable(): void {
|
|
1009
|
+
if (isReleaseImage(this.image))
|
|
1010
|
+
throw new Error(
|
|
1011
|
+
`${this.image} is a published release image — it is pulled (docker pull ${this.image}), never built over; ` +
|
|
1012
|
+
`set [companion] image = "${COMPANION_IMAGE}" to build one from this checkout`,
|
|
1013
|
+
);
|
|
951
1014
|
const problem = imageBuildProblem(this.cfg.build_context, (p) => this.fs.exists(p));
|
|
952
1015
|
if (problem !== null) throw new Error(problem);
|
|
953
1016
|
}
|
|
@@ -960,21 +1023,21 @@ export class CompanionLifecycle {
|
|
|
960
1023
|
async buildImage(onProgress?: (line: string) => void): Promise<void> {
|
|
961
1024
|
this.checkBuildable();
|
|
962
1025
|
const ctx = this.cfg.build_context;
|
|
963
|
-
onProgress?.(`building ${this.
|
|
1026
|
+
onProgress?.(`building ${this.image} image (first run — takes a few minutes)…`);
|
|
964
1027
|
// The stamp is the checkout's; the build reads an assembled copy of the
|
|
965
1028
|
// release context, so it works on the legacy builder too.
|
|
966
1029
|
const stamp = this.stampOf(ctx);
|
|
967
1030
|
const built = this.assembleContext(ctx);
|
|
968
1031
|
let res;
|
|
969
1032
|
try {
|
|
970
|
-
res = await this.docker(appImageBuildArgv(this.
|
|
1033
|
+
res = await this.docker(appImageBuildArgv(this.image, built, stamp), {
|
|
971
1034
|
timeoutMs: 1_800_000,
|
|
972
1035
|
});
|
|
973
1036
|
} finally {
|
|
974
1037
|
if (built !== ctx) rmSync(built, { recursive: true, force: true });
|
|
975
1038
|
}
|
|
976
|
-
if (!res.ok) throw new Error(`docker build ${this.
|
|
977
|
-
onProgress?.(`${this.
|
|
1039
|
+
if (!res.ok) throw new Error(`docker build ${this.image} failed: ${errText(res)}`);
|
|
1040
|
+
onProgress?.(`${this.image} image built`);
|
|
978
1041
|
}
|
|
979
1042
|
|
|
980
1043
|
// ---- memory wiring --------------------------------------------------------
|
|
@@ -1266,8 +1329,13 @@ export class CompanionLifecycle {
|
|
|
1266
1329
|
onProgress?.(`companion container unstartable (${firstLine(errText(started))}) — recreating`);
|
|
1267
1330
|
await this.forceRemove();
|
|
1268
1331
|
}
|
|
1269
|
-
// Self-healing first run:
|
|
1270
|
-
|
|
1332
|
+
// Self-healing first run: the pulled release image when there is one,
|
|
1333
|
+
// else build the image rather than failing on its absence — or, for a
|
|
1334
|
+
// configured release image, pull it (a release tag is never built over).
|
|
1335
|
+
if (!(await this.adoptPreferredImage(onProgress))) {
|
|
1336
|
+
if (isReleaseImage(this.image)) await this.pullImage(onProgress);
|
|
1337
|
+
else await this.buildImage(onProgress);
|
|
1338
|
+
}
|
|
1271
1339
|
const homes = this.hostHomes();
|
|
1272
1340
|
this.ensureDirs([
|
|
1273
1341
|
this.cfg.feed_dir,
|
|
@@ -1280,7 +1348,7 @@ export class CompanionLifecycle {
|
|
|
1280
1348
|
homes.github,
|
|
1281
1349
|
]);
|
|
1282
1350
|
const created = await this.docker(this.runArgv(), { timeoutMs: 120_000 });
|
|
1283
|
-
if (!created.ok) throw new Error(`docker run ${this.
|
|
1351
|
+
if (!created.ok) throw new Error(`docker run ${this.image} failed: ${errText(created)}`);
|
|
1284
1352
|
return st.exists || recreating ? "recreated" : "created";
|
|
1285
1353
|
}
|
|
1286
1354
|
|
package/src/tui/connect.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// the exact remedy; pressing `s` is the operator asking for this prompt.
|
|
12
12
|
import { spawnSync } from "node:child_process";
|
|
13
13
|
import type { TuiConfig } from "../config.js";
|
|
14
|
-
import { transportOf } from "./remote.js";
|
|
14
|
+
import { NO_SSH_HOST_STDERR, transportOf } from "./remote.js";
|
|
15
15
|
|
|
16
16
|
export function masterCheckArgv(cfg: TuiConfig): string[] {
|
|
17
17
|
return ["-O", "check", cfg.ssh_host];
|
|
@@ -51,6 +51,12 @@ export function ensureSshMaster(
|
|
|
51
51
|
// at the call sites) so the board's `s` reconnect hook and any future caller
|
|
52
52
|
// inherit the guarantee: in local mode this function spawns nothing.
|
|
53
53
|
if (transportOf(cfg) === "local") return { kind: "local" };
|
|
54
|
+
// No host, nothing to connect to: `ssh "" true` only ever fails on name
|
|
55
|
+
// resolution, and the passphrase prompt it promises would never come.
|
|
56
|
+
if (cfg.ssh_host.trim() === "") {
|
|
57
|
+
say(NO_SSH_HOST_STDERR);
|
|
58
|
+
return { kind: "failed", detail: NO_SSH_HOST_STDERR };
|
|
59
|
+
}
|
|
54
60
|
if (ssh(masterCheckArgv(cfg), false).status === 0) return { kind: "alive" };
|
|
55
61
|
say(
|
|
56
62
|
`connecting to ${cfg.ssh_host} — enter the GCP key passphrase if asked (once; the connection is shared for 4h)`,
|
package/src/tui/filters.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// they never touch the poller's snapshot or the memory store.
|
|
6
6
|
import { normalizeTag } from "../memory/store.js";
|
|
7
7
|
import type { MemoryEntry } from "./memory.js";
|
|
8
|
-
import {
|
|
8
|
+
import type { StatusSnapshot } from "../state.js";
|
|
9
|
+
import { offeredPrs, sortPrs, type SnapshotPr } from "./model.js";
|
|
9
10
|
|
|
10
11
|
export interface BoardFilter {
|
|
11
12
|
/** Case-insensitive substring match against the PR author login. */
|
|
@@ -76,10 +77,13 @@ export function applyMemoryFilter<T extends Pick<MemoryEntry, "tags">>(
|
|
|
76
77
|
* companion driver all derive rows from here, so a verb can never address a
|
|
77
78
|
* row the filter hid. */
|
|
78
79
|
export function visiblePrs(view: {
|
|
79
|
-
snapshot?: { prs: SnapshotPr[] };
|
|
80
|
+
snapshot?: { prs: SnapshotPr[]; open_prs?: StatusSnapshot["open_prs"] };
|
|
80
81
|
boardFilter?: BoardFilter | null;
|
|
81
82
|
}): SnapshotPr[] {
|
|
82
|
-
|
|
83
|
+
const tracked = applyBoardFilter(sortPrs(view.snapshot?.prs ?? []), view.boardFilter);
|
|
84
|
+
// The repo's other open PRs follow, offered for review — under a filter the
|
|
85
|
+
// board shows only what the filter names.
|
|
86
|
+
return view.boardFilter != null ? tracked : [...tracked, ...offeredPrs(view.snapshot)];
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
/** The memory view's indicator, same shape as the board's. */
|
package/src/tui/ghread.ts
CHANGED
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
// `companion-guard` additionally vetoes write-shaped calls against the API
|
|
24
24
|
// (belt); the token's own scope is the brace.
|
|
25
25
|
//
|
|
26
|
+
// Under `[github] auth = "user"` (the person's own credential, SECURITY.md)
|
|
27
|
+
// there is no App to mint from. The companion then gets a COPY of
|
|
28
|
+
// the person's token file (`<secrets dir>/github_token`), re-copied whenever
|
|
29
|
+
// the host refresher rewrites it, in the same env file and the same
|
|
30
|
+
// read-only mount. That token is the person's and is NOT down-scoped: in this
|
|
31
|
+
// mode `companion-guard`'s veto of write-shaped calls is the only fence, and
|
|
32
|
+
// the env file's header says so.
|
|
33
|
+
//
|
|
26
34
|
// Everything here is total at the edge the TUI calls: `refresh()` answers an
|
|
27
35
|
// outcome, never throws — a companion without GitHub is under-equipped, never
|
|
28
36
|
// a companion that does not start.
|
|
@@ -160,7 +168,11 @@ const TOKEN_VALUE_RE = /^[A-Za-z0-9_.-]+$/;
|
|
|
160
168
|
/** The env file's text. Every value is checked against a character set that
|
|
161
169
|
* is inert in both a docker env file and a `source`d shell file. A token
|
|
162
170
|
* outside that set is refused, and the refusal never echoes it. */
|
|
163
|
-
export function renderTokenEnv(
|
|
171
|
+
export function renderTokenEnv(
|
|
172
|
+
tok: ReadToken,
|
|
173
|
+
repo: string,
|
|
174
|
+
source: "app" | "user" = "app",
|
|
175
|
+
): string {
|
|
164
176
|
if (!TOKEN_VALUE_RE.test(tok.token))
|
|
165
177
|
throw new Error(
|
|
166
178
|
"the minted token has a character an env file cannot carry safely (only A-Z a-z 0-9 _ . - are allowed)",
|
|
@@ -169,11 +181,16 @@ export function renderTokenEnv(tok: ReadToken, repo: string): string {
|
|
|
169
181
|
throw new Error("the token expiry is not a timestamp");
|
|
170
182
|
if (!REPO_RE.test(repo))
|
|
171
183
|
throw new Error(`[github] repo ${JSON.stringify(repo)} is not owner/name`);
|
|
184
|
+
const header =
|
|
185
|
+
source === "user"
|
|
186
|
+
? '# Copied on the host by qcr from the person\'s own GitHub token ([github] auth = "user").\n' +
|
|
187
|
+
"# It is NOT down-scoped: it can do what that person can. The companion uses it for reads\n" +
|
|
188
|
+
"# only, and companion-guard vetoes write-shaped calls. Re-copied when the host refreshes it.\n"
|
|
189
|
+
: "# Minted on the host by qcr: a READ-ONLY GitHub App installation token\n" +
|
|
190
|
+
"# (contents / issues / pull requests: read) for one repository. Re-minted before\n" +
|
|
191
|
+
"# it expires. It cannot write to GitHub.\n";
|
|
172
192
|
return (
|
|
173
|
-
|
|
174
|
-
"# (contents / issues / pull requests: read) for one repository. Re-minted before\n" +
|
|
175
|
-
"# it expires. It cannot write to GitHub.\n" +
|
|
176
|
-
`${TOKEN_VAR}=${tok.token}\n${EXPIRES_VAR}=${tok.expires_at}\n${REPO_VAR}=${repo}\n`
|
|
193
|
+
header + `${TOKEN_VAR}=${tok.token}\n${EXPIRES_VAR}=${tok.expires_at}\n${REPO_VAR}=${repo}\n`
|
|
177
194
|
);
|
|
178
195
|
}
|
|
179
196
|
|
|
@@ -227,10 +244,22 @@ export function tokenExpiry(dir: string, repo?: string): number | undefined {
|
|
|
227
244
|
return Number.isFinite(ms) ? ms : undefined;
|
|
228
245
|
}
|
|
229
246
|
|
|
247
|
+
/** The token the env file holds, or undefined. */
|
|
248
|
+
function heldToken(dir: string): string | undefined {
|
|
249
|
+
try {
|
|
250
|
+
const text = readFileSync(join(dir, TOKEN_FILE), "utf8");
|
|
251
|
+
return new RegExp(`^${TOKEN_VAR}=(.*)$`, "m").exec(text)?.[1]?.trim();
|
|
252
|
+
} catch {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
230
257
|
export type TokenOutcome =
|
|
231
258
|
/** The file holds a token with more than the margin left: nothing minted. */
|
|
232
259
|
| { kind: "fresh"; expiresAt: string }
|
|
233
260
|
| { kind: "minted"; expiresAt: string }
|
|
261
|
+
/** `auth = "user"`: the person's token file was copied in (it changed). */
|
|
262
|
+
| { kind: "copied"; expiresAt: string }
|
|
234
263
|
/** No App credential on this host: the companion simply has no GitHub. */
|
|
235
264
|
| { kind: "unavailable"; reason: string }
|
|
236
265
|
| { kind: "failed"; reason: string };
|
|
@@ -242,6 +271,8 @@ export function tokenOutcomeLine(o: TokenOutcome): string {
|
|
|
242
271
|
return `read-only GitHub token valid until ${o.expiresAt}`;
|
|
243
272
|
case "minted":
|
|
244
273
|
return `read-only GitHub token minted (valid until ${o.expiresAt})`;
|
|
274
|
+
case "copied":
|
|
275
|
+
return "GitHub token copied from the person's own token file (auth = user; used read-only)";
|
|
245
276
|
case "unavailable":
|
|
246
277
|
return `no read-only GitHub token — ${o.reason}`;
|
|
247
278
|
case "failed":
|
|
@@ -254,9 +285,12 @@ export interface CompanionGithubTokenOptions {
|
|
|
254
285
|
dir: string;
|
|
255
286
|
/** `[github] repo`, owner/name. */
|
|
256
287
|
repo: string;
|
|
257
|
-
/** The resolved
|
|
258
|
-
* host; nothing from it is
|
|
288
|
+
/** The resolved secrets dir (`app_id`, `app.pem`; or `github_token` under
|
|
289
|
+
* `auth = "user"`). Read here, on the host; under "app" nothing from it is
|
|
290
|
+
* ever written into `dir`. */
|
|
259
291
|
secretsDir: string;
|
|
292
|
+
/** `[github] auth`. Absent ⇒ "app". */
|
|
293
|
+
auth?: "app" | "user";
|
|
260
294
|
fetchFn?: FetchFn;
|
|
261
295
|
apiBase?: string;
|
|
262
296
|
now?: () => number;
|
|
@@ -276,6 +310,7 @@ export class CompanionGithubToken {
|
|
|
276
310
|
}
|
|
277
311
|
|
|
278
312
|
private async doRefresh(): Promise<TokenOutcome> {
|
|
313
|
+
if (this.opts.auth === "user") return this.copyUserToken();
|
|
279
314
|
const now = (this.opts.now ?? Date.now)();
|
|
280
315
|
const current = tokenExpiry(this.opts.dir, this.opts.repo);
|
|
281
316
|
if (current !== undefined && current - now > REFRESH_MARGIN_MS)
|
|
@@ -307,6 +342,38 @@ export class CompanionGithubToken {
|
|
|
307
342
|
}
|
|
308
343
|
}
|
|
309
344
|
|
|
345
|
+
/** `auth = "user"`: copy the person's token file in when it differs from
|
|
346
|
+
* what the env file holds (or the repo changed). The expiry the file
|
|
347
|
+
* carries is a re-check time, not GitHub's: a user token's own expiry is
|
|
348
|
+
* not known here. */
|
|
349
|
+
private copyUserToken(): TokenOutcome {
|
|
350
|
+
const source = join(this.opts.secretsDir, "github_token");
|
|
351
|
+
let token: string;
|
|
352
|
+
try {
|
|
353
|
+
token = readFileSync(source, "utf8").trim();
|
|
354
|
+
} catch {
|
|
355
|
+
return {
|
|
356
|
+
kind: "unavailable",
|
|
357
|
+
reason: `no user token at ${source} (run \`qcr github-token sync\` on the host)`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (token === "")
|
|
361
|
+
return { kind: "unavailable", reason: `the user token file ${source} is empty` };
|
|
362
|
+
const expiresAt = new Date((this.opts.now ?? Date.now)() + REFRESH_MARGIN_MS * 2).toISOString();
|
|
363
|
+
const heldUntil = tokenExpiry(this.opts.dir, this.opts.repo);
|
|
364
|
+
if (heldUntil !== undefined && heldToken(this.opts.dir) === token)
|
|
365
|
+
return { kind: "fresh", expiresAt: new Date(heldUntil).toISOString() };
|
|
366
|
+
try {
|
|
367
|
+
writeTokenEnv(
|
|
368
|
+
this.opts.dir,
|
|
369
|
+
renderTokenEnv({ token, expires_at: expiresAt, permissions: {} }, this.opts.repo, "user"),
|
|
370
|
+
);
|
|
371
|
+
return { kind: "copied", expiresAt };
|
|
372
|
+
} catch (err) {
|
|
373
|
+
return { kind: "failed", reason: err instanceof Error ? err.message : String(err) };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
310
377
|
/** Check every `everyMs`; each outcome goes to `onOutcome`. Idempotent. */
|
|
311
378
|
start(onOutcome: (o: TokenOutcome) => void, everyMs: number = CHECK_EVERY_MS): void {
|
|
312
379
|
if (this.timer) return;
|
package/src/tui/index.ts
CHANGED
|
@@ -34,7 +34,10 @@ import {
|
|
|
34
34
|
RELOAD_EXIT_CODE,
|
|
35
35
|
resumeStatePath,
|
|
36
36
|
} from "./reload.js";
|
|
37
|
-
import { killInFlightSsh, runnerFor, transportOf } from "./remote.js";
|
|
37
|
+
import { NO_SSH_HOST_STDERR, killInFlightSsh, runnerFor, transportOf } from "./remote.js";
|
|
38
|
+
import { diagnoseLocalMemory } from "./memforward.js";
|
|
39
|
+
import { realProbeRunner } from "./sshtriage.js";
|
|
40
|
+
import { setupLine } from "./setupstate.js";
|
|
38
41
|
import { chainOnPoll } from "./poll-hooks.js";
|
|
39
42
|
import { deliverMessage } from "./msg.js";
|
|
40
43
|
import type { ConnectionDiagnosis } from "./sshtriage.js";
|
|
@@ -60,7 +63,7 @@ export function parseTarget(
|
|
|
60
63
|
return { kind: "bad", reason: `unknown target '${arg}' — expected a PR number` };
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
export const TUI_HELP = `qcr tui — live board and operator console for the review daemon (over ssh)
|
|
66
|
+
export const TUI_HELP = `qcr tui — live board and operator console for the review daemon (over ssh, or local docker)
|
|
64
67
|
|
|
65
68
|
usage: qcr tui [<pr>] [--no-companion] [--no-reload]
|
|
66
69
|
|
|
@@ -95,7 +98,8 @@ keys: 1…8 the eight surfaces — board · memory · digest · tickets · deplo
|
|
|
95
98
|
board: j/k select · ⏎ focus the PR's pi pane · m quick-message ·
|
|
96
99
|
g github · u unwedge · e events overlay · I set up the repo ·
|
|
97
100
|
Ctrl-q release focus · Ctrl-c ×3 hard-kill the TUI ·
|
|
98
|
-
s
|
|
101
|
+
s re-check the connection (VM: ssh, the one passphrase prompt;
|
|
102
|
+
local: the daemon container) · r refresh ·
|
|
99
103
|
? full key map · q quit
|
|
100
104
|
|
|
101
105
|
troubleshooting:
|
|
@@ -106,6 +110,12 @@ troubleshooting:
|
|
|
106
110
|
not answering. Nothing is run for you; s re-checks and
|
|
107
111
|
reports, and offers the one GCP passphrase prompt when a
|
|
108
112
|
key is what is missing
|
|
113
|
+
local route [tui] target = "local" (or an empty ssh_host on a compose
|
|
114
|
+
project): every read is docker exec into the daemon
|
|
115
|
+
container, no ssh. The board names an absent or stopped
|
|
116
|
+
container with the docker compose command that starts it
|
|
117
|
+
setup not finished the onboarding checklist is not complete: run qcr onboard
|
|
118
|
+
(the board names the next step)
|
|
109
119
|
▲ DATA STALE ssh works but status.json stopped updating — the
|
|
110
120
|
container is down or the daemon hung (qcr-side:
|
|
111
121
|
ssh <host> sudo docker ps / docker logs)
|
|
@@ -355,6 +365,8 @@ export interface RunTuiDeps {
|
|
|
355
365
|
input?: NodeJS.ReadStream;
|
|
356
366
|
config?: Config;
|
|
357
367
|
exit?: (code: number) => void;
|
|
368
|
+
/** The route facts (src/tui/route.ts); default: read from this checkout. */
|
|
369
|
+
routeFacts?: import("./route.js").RouteFacts;
|
|
358
370
|
}
|
|
359
371
|
|
|
360
372
|
/** First slice of the quit-time wait for an in-flight bring-up. */
|
|
@@ -440,7 +452,15 @@ export async function runTui(argv: string | string[] = [], deps: RunTuiDeps = {}
|
|
|
440
452
|
|
|
441
453
|
// ---- phase 0: local only, ending in the first frame -----------------------
|
|
442
454
|
|
|
443
|
-
const
|
|
455
|
+
const fileConfig = deps.config ?? loadConfig();
|
|
456
|
+
// The route — which daemon the board reads, and how — from [tui] plus the
|
|
457
|
+
// facts this checkout holds locally (the compose project's .env, the
|
|
458
|
+
// onboarding checklist, the Docker socket's mode). Two small file reads and
|
|
459
|
+
// one access(2): no docker, network or walk, so it may precede the frame.
|
|
460
|
+
const { readRouteFacts, resolveTuiRoute, pinnedVersion } = await import("./route.js");
|
|
461
|
+
const routeFacts = deps.routeFacts ?? readRouteFacts(sandboxRoot());
|
|
462
|
+
const route = resolveTuiRoute(fileConfig.tui, routeFacts);
|
|
463
|
+
const loaded: Config = { ...fileConfig, tui: route.cfg };
|
|
444
464
|
// In the containerised TUI the companion is a container the host wrapper
|
|
445
465
|
// runs, reached through a shared tmux socket. Its channel dirs are the
|
|
446
466
|
// host's, named by the wrapper in env; nothing here can run docker.
|
|
@@ -454,6 +474,11 @@ export async function runTui(argv: string | string[] = [], deps: RunTuiDeps = {}
|
|
|
454
474
|
const runner = runnerFor(cfg);
|
|
455
475
|
|
|
456
476
|
const app = new App(cfg, runner, out, input);
|
|
477
|
+
// A half-finished onboarding is stated on the first frame: the checklist is
|
|
478
|
+
// the file read above, and the step titles are the wizard's own registry.
|
|
479
|
+
const { setupStateOf } = await import("./setupstate.js");
|
|
480
|
+
app.view.setup = await setupStateOf(routeFacts.checklist);
|
|
481
|
+
for (const line of route.notes) app.notice(line);
|
|
457
482
|
|
|
458
483
|
// The terminal must come back whatever kills us — raw mode + alt screen
|
|
459
484
|
// surviving a crash is exactly the "only closing the terminal works" trap.
|
|
@@ -560,6 +585,17 @@ export async function runTui(argv: string | string[] = [], deps: RunTuiDeps = {}
|
|
|
560
585
|
// (nativecompanion.ts). The containerised TUI keeps its wrapper-run
|
|
561
586
|
// container: it has no Docker of its own to ask.
|
|
562
587
|
const containerLifecycle = new CompanionLifecycle(full.companion, realDockerRunner);
|
|
588
|
+
// On a compose box the release image is already pulled (QCR_VERSION in the
|
|
589
|
+
// project .env): a first start uses it rather than building one from this
|
|
590
|
+
// checkout. An explicit [companion] image, or a local build already
|
|
591
|
+
// present, still wins (CompanionLifecycle.ensureUp).
|
|
592
|
+
{
|
|
593
|
+
const version = pinnedVersion(routeFacts.projectEnv);
|
|
594
|
+
if (version !== "") {
|
|
595
|
+
const { releaseImage } = await import("../onboard/steps/compose.js");
|
|
596
|
+
containerLifecycle.preferredImage = releaseImage(version);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
563
599
|
const selecting = socketMode
|
|
564
600
|
? undefined
|
|
565
601
|
: new SelectingCompanion(containerLifecycle, realDockerRunner, {
|
|
@@ -593,6 +629,7 @@ export async function runTui(argv: string | string[] = [], deps: RunTuiDeps = {}
|
|
|
593
629
|
dir: companionGithubDir(socketMode ? laptopInstallEnv(process.env) : undefined),
|
|
594
630
|
repo: full.github.repo,
|
|
595
631
|
secretsDir: full.github.secrets_dir,
|
|
632
|
+
auth: full.github.auth ?? "app",
|
|
596
633
|
});
|
|
597
634
|
let configMirrorTimer: NodeJS.Timeout | undefined;
|
|
598
635
|
const CONFIG_MIRROR_EVERY_MS = 30_000;
|
|
@@ -620,8 +657,17 @@ export async function runTui(argv: string | string[] = [], deps: RunTuiDeps = {}
|
|
|
620
657
|
// never written to the normal screen, where the alt screen's clear would
|
|
621
658
|
// erase them (a visible flicker). With the companion off the same column
|
|
622
659
|
// carries the skipped steps, and stays as the place that says why it is off.
|
|
623
|
-
const { StartupProgress } = await import("./startup.js");
|
|
624
|
-
|
|
660
|
+
const { StartupProgress, STARTUP_STEPS } = await import("./startup.js");
|
|
661
|
+
// The connection step is named for the route it checks: ssh to the VM, or
|
|
662
|
+
// the daemon container on this machine.
|
|
663
|
+
const startupSteps = STARTUP_STEPS.map((step) =>
|
|
664
|
+
step.key === "ssh" && transportOf(cfg) === "local"
|
|
665
|
+
? { ...step, label: "daemon container" }
|
|
666
|
+
: step.key === "ssh" && route.unconfigured !== undefined
|
|
667
|
+
? { ...step, label: "connection" }
|
|
668
|
+
: step,
|
|
669
|
+
);
|
|
670
|
+
const progress = new StartupProgress(startupSteps, {
|
|
625
671
|
onUpdate: (p) => {
|
|
626
672
|
// Kept after the bring-up settles on purpose: a `✗ companion container ·
|
|
627
673
|
// docker is not running` is the most useful thing the column can show,
|
|
@@ -714,12 +760,20 @@ export async function runTui(argv: string | string[] = [], deps: RunTuiDeps = {}
|
|
|
714
760
|
// `[openviking] endpoint = "remote"`, else the daemon container.
|
|
715
761
|
container: tuiMemoryContainer(cfg),
|
|
716
762
|
// A local engine runs in Docker; with none here, memory is off — said
|
|
717
|
-
// once as the forward's state, never retried as a failure.
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
:
|
|
763
|
+
// once as the forward's state, never retried as a failure. With no ssh
|
|
764
|
+
// host there is nowhere to forward to: off, with the one remedy, rather
|
|
765
|
+
// than a relay dialing the script's default host.
|
|
766
|
+
...(transportOf(cfg) === "ssh" && cfg.ssh_host.trim() === ""
|
|
767
|
+
? { offReason: () => route.unconfigured ?? NO_SSH_HOST_STDERR }
|
|
768
|
+
: !socketMode && transportOf(cfg) === "local"
|
|
769
|
+
? {
|
|
770
|
+
offReason: async () => ((await dockerUp()) ? undefined : MEMORY_OFF_NO_DOCKER),
|
|
771
|
+
// A forward that binds but reaches nothing: say whether the memory
|
|
772
|
+
// container is there at all, and how it is started.
|
|
773
|
+
diagnoseUpstream: () =>
|
|
774
|
+
diagnoseLocalMemory(cfg, routeFacts.projectDir, realProbeRunner),
|
|
775
|
+
}
|
|
776
|
+
: {}),
|
|
723
777
|
log: (line) => app.notice(line),
|
|
724
778
|
});
|
|
725
779
|
const { buildIssueSink, ticketProjection } = await import("./issuesink.js");
|
|
@@ -1005,12 +1059,26 @@ export async function runTui(argv: string | string[] = [], deps: RunTuiDeps = {}
|
|
|
1005
1059
|
// gating the first paint and, on a bad day, an interactive prompt. Nothing
|
|
1006
1060
|
// here is self-healing by design; the TUI reports the cause and the exact
|
|
1007
1061
|
// command, and `s` re-runs the check.
|
|
1008
|
-
const { diagnosisLine, triageConnection } = await import("./sshtriage.js");
|
|
1062
|
+
const { connectionUsable, diagnosisLine, triageConnection } = await import("./sshtriage.js");
|
|
1063
|
+
const triageDeps = {
|
|
1064
|
+
projectDir: routeFacts.projectDir,
|
|
1065
|
+
...(route.unconfigured !== undefined ? { unconfigured: route.unconfigured } : {}),
|
|
1066
|
+
};
|
|
1009
1067
|
const applyDiagnosis = (d: ConnectionDiagnosis): void => {
|
|
1010
1068
|
app.view.connection = d;
|
|
1011
|
-
|
|
1069
|
+
const usable = connectionUsable(d);
|
|
1070
|
+
// Under an unfinished onboarding a stack that is not up yet is expected,
|
|
1071
|
+
// not a fault: the step is skipped with the setup line, never failed.
|
|
1072
|
+
if (usable) progress.done("ssh", d.cause);
|
|
1073
|
+
else if (app.view.setup) progress.skip("ssh", setupLine(app.view.setup));
|
|
1012
1074
|
else progress.fail("ssh", diagnosisLine(d));
|
|
1013
|
-
app.notice(
|
|
1075
|
+
app.notice(
|
|
1076
|
+
usable
|
|
1077
|
+
? `✓ ${d.cause}`
|
|
1078
|
+
: app.view.setup
|
|
1079
|
+
? setupLine(app.view.setup)
|
|
1080
|
+
: `✖ ${diagnosisLine(d)}`,
|
|
1081
|
+
);
|
|
1014
1082
|
app.repaint();
|
|
1015
1083
|
};
|
|
1016
1084
|
// `s` starts its own async chain, independent of the bring-up. Tracked for
|
|
@@ -1018,9 +1086,13 @@ export async function runTui(argv: string | string[] = [], deps: RunTuiDeps = {}
|
|
|
1018
1086
|
let pendingReconnect: Promise<void> = Promise.resolve();
|
|
1019
1087
|
app.hooks.reconnect = () => {
|
|
1020
1088
|
progress.begin("ssh", "re-checking");
|
|
1021
|
-
app.notice(
|
|
1089
|
+
app.notice(
|
|
1090
|
+
transportOf(cfg) === "local"
|
|
1091
|
+
? "checking the daemon container…"
|
|
1092
|
+
: "checking the connection to the VM…",
|
|
1093
|
+
);
|
|
1022
1094
|
pendingReconnect = (async () => {
|
|
1023
|
-
let d = await triageConnection(cfg);
|
|
1095
|
+
let d = await triageConnection(cfg, triageDeps);
|
|
1024
1096
|
// A missing key is the one class the operator can clear from here: `s`
|
|
1025
1097
|
// is them asking for the single native passphrase prompt. A wedged
|
|
1026
1098
|
// master or a dead host is reported with its command instead — prompting
|
|
@@ -1029,7 +1101,7 @@ export async function runTui(argv: string | string[] = [], deps: RunTuiDeps = {}
|
|
|
1029
1101
|
onRealTerminal(() => {
|
|
1030
1102
|
ensureSshMaster(cfg);
|
|
1031
1103
|
});
|
|
1032
|
-
d = await triageConnection(cfg);
|
|
1104
|
+
d = await triageConnection(cfg, triageDeps);
|
|
1033
1105
|
}
|
|
1034
1106
|
applyDiagnosis(d);
|
|
1035
1107
|
// Awaited, not fired and forgotten: it is part of "re-run the check and
|
|
@@ -1397,7 +1469,7 @@ export async function runTui(argv: string | string[] = [], deps: RunTuiDeps = {}
|
|
|
1397
1469
|
// says "nobody has reached this yet", which would be a lie the moment the
|
|
1398
1470
|
// probes start.
|
|
1399
1471
|
progress.begin("ssh");
|
|
1400
|
-
const triaged = triageConnection(cfg)
|
|
1472
|
+
const triaged = triageConnection(cfg, triageDeps)
|
|
1401
1473
|
.then(applyDiagnosis)
|
|
1402
1474
|
.catch((err: unknown) =>
|
|
1403
1475
|
progress.fail("ssh", `probe failed: ${String(err instanceof Error ? err.message : err)}`),
|
|
@@ -1439,12 +1511,31 @@ export async function runTui(argv: string | string[] = [], deps: RunTuiDeps = {}
|
|
|
1439
1511
|
return;
|
|
1440
1512
|
}
|
|
1441
1513
|
if (step?.state === "running" && a.poller.state.consecutiveFailures >= 2) {
|
|
1442
|
-
progress.
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1514
|
+
if (app.view.setup) progress.skip("poll", setupLine(app.view.setup));
|
|
1515
|
+
else
|
|
1516
|
+
progress.fail(
|
|
1517
|
+
"poll",
|
|
1518
|
+
app.view.connection ? diagnosisLine(app.view.connection) : "status.json unreadable",
|
|
1519
|
+
);
|
|
1446
1520
|
}
|
|
1447
1521
|
});
|
|
1522
|
+
// The checklist moves while the wizard runs in another terminal: re-read it
|
|
1523
|
+
// each poll (one small file), and recompute the next step only when it
|
|
1524
|
+
// changed. Finishing onboarding clears the banner on its own.
|
|
1525
|
+
{
|
|
1526
|
+
const { readChecklist } = await import("../onboard/engine.js");
|
|
1527
|
+
let seen = routeFacts.checklist?.updated_at ?? null;
|
|
1528
|
+
chainOnPoll(app, () => {
|
|
1529
|
+
const now = readChecklist(join(routeFacts.projectDir, "state"));
|
|
1530
|
+
const stamp = now?.updated_at ?? null;
|
|
1531
|
+
if (stamp === seen) return;
|
|
1532
|
+
seen = stamp;
|
|
1533
|
+
void setupStateOf(now).then((next) => {
|
|
1534
|
+
app.view.setup = next;
|
|
1535
|
+
app.repaint();
|
|
1536
|
+
});
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1448
1539
|
|
|
1449
1540
|
// Restore a hot-reload snapshot before run(), so the first paint is already
|
|
1450
1541
|
// on the right tab with the right selection.
|