@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/memforward.ts
CHANGED
|
@@ -40,6 +40,9 @@
|
|
|
40
40
|
// status` reads them) rather than painted as zero.
|
|
41
41
|
import { pathToFileURL } from "node:url";
|
|
42
42
|
import { join } from "node:path";
|
|
43
|
+
import type { TuiConfig } from "../config.js";
|
|
44
|
+
import { shellQuote } from "./remote.js";
|
|
45
|
+
import type { ProbeRunner } from "./sshtriage.js";
|
|
43
46
|
|
|
44
47
|
/** The container OpenViking runs in, as the TUI and the memory forward reach
|
|
45
48
|
* it: the memory sidecar when `[tui] memory_container` names one, else the
|
|
@@ -50,6 +53,33 @@ export function tuiMemoryContainer(tui: { container: string; memory_container?:
|
|
|
50
53
|
return named !== "" ? named : tui.container;
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The local route's account of the memory container, for a forward that
|
|
58
|
+
* binds but reaches nothing: absent, stopped, or running but not answering
|
|
59
|
+
* yet. One `docker inspect`; undefined when docker itself gave no usable
|
|
60
|
+
* answer (the generic line stands).
|
|
61
|
+
*/
|
|
62
|
+
export async function diagnoseLocalMemory(
|
|
63
|
+
cfg: Pick<TuiConfig, "container" | "memory_container" | "sudo_docker">,
|
|
64
|
+
projectDir: string,
|
|
65
|
+
run: ProbeRunner,
|
|
66
|
+
): Promise<string | undefined> {
|
|
67
|
+
const container = tuiMemoryContainer(cfg);
|
|
68
|
+
const inspect = ["inspect", "-f", "{{.State.Status}}", container];
|
|
69
|
+
const res = cfg.sudo_docker
|
|
70
|
+
? await run("sudo", ["-n", "docker", ...inspect], 5_000)
|
|
71
|
+
: await run("docker", inspect, 5_000);
|
|
72
|
+
const up = `docker compose --project-directory ${shellQuote(projectDir)} up -d memory`;
|
|
73
|
+
const state = res.stdout.trim();
|
|
74
|
+
if (res.status === 0 && state === "running")
|
|
75
|
+
return `the memory container ${container} is running but OpenViking is not answering yet (it takes a minute after a start) — r retries`;
|
|
76
|
+
if (res.status === 0)
|
|
77
|
+
return `the memory container ${container} is ${state || "not running"} — start it: ${up}`;
|
|
78
|
+
if (/no such (object|container)/i.test(`${res.stderr}\n${res.stdout}`))
|
|
79
|
+
return `the memory container ${container} is not running — it starts at onboarding's bring-up step, or: ${up}`;
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
53
83
|
/** What the forward's `stats()` reports — the fields painted. */
|
|
54
84
|
export interface ForwardStats {
|
|
55
85
|
maxRelays: number;
|
|
@@ -166,6 +196,10 @@ export interface MemForwardDeps {
|
|
|
166
196
|
* forward `off` with it as the detail — no probe, no bind, no retry, and
|
|
167
197
|
* nothing that reads as a failure. */
|
|
168
198
|
offReason?: () => string | undefined | Promise<string | undefined>;
|
|
199
|
+
/** Asked when the forward listens but nothing answers through it: a precise
|
|
200
|
+
* account of the upstream (the memory container absent, stopped, still
|
|
201
|
+
* starting), or undefined to keep the generic line. Never throws. */
|
|
202
|
+
diagnoseUpstream?: () => Promise<string | undefined>;
|
|
169
203
|
}
|
|
170
204
|
|
|
171
205
|
const DEFAULT_PROBE_TIMEOUT_MS = 1_500;
|
|
@@ -195,12 +229,13 @@ async function httpProbe(baseUrl: string, timeoutMs: number): Promise<boolean> {
|
|
|
195
229
|
*/
|
|
196
230
|
export class MemoryForward {
|
|
197
231
|
private readonly deps: Required<
|
|
198
|
-
Omit<MemForwardDeps, "env" | "sshHost" | "container" | "offReason">
|
|
232
|
+
Omit<MemForwardDeps, "env" | "sshHost" | "container" | "offReason" | "diagnoseUpstream">
|
|
199
233
|
> & {
|
|
200
234
|
env: NodeJS.ProcessEnv;
|
|
201
235
|
sshHost?: string;
|
|
202
236
|
container?: string;
|
|
203
237
|
offReason?: MemForwardDeps["offReason"];
|
|
238
|
+
diagnoseUpstream?: MemForwardDeps["diagnoseUpstream"];
|
|
204
239
|
};
|
|
205
240
|
private handle: ForwarderHandle | null = null;
|
|
206
241
|
/** How many of its addresses our forward bound, kept with the handle so
|
|
@@ -228,6 +263,7 @@ export class MemoryForward {
|
|
|
228
263
|
...(deps.sshHost ? { sshHost: deps.sshHost } : {}),
|
|
229
264
|
...(deps.container ? { container: deps.container } : {}),
|
|
230
265
|
...(deps.offReason ? { offReason: deps.offReason } : {}),
|
|
266
|
+
...(deps.diagnoseUpstream ? { diagnoseUpstream: deps.diagnoseUpstream } : {}),
|
|
231
267
|
};
|
|
232
268
|
this.status = {
|
|
233
269
|
state: "off",
|
|
@@ -295,7 +331,8 @@ export class MemoryForward {
|
|
|
295
331
|
alive ? "owned" : "failed",
|
|
296
332
|
alive
|
|
297
333
|
? this.ownedDetail(this.binds?.bound, this.binds?.wanted)
|
|
298
|
-
:
|
|
334
|
+
: ((await this.upstreamDetail()) ??
|
|
335
|
+
"the forward we started stopped answering — r retries"),
|
|
299
336
|
);
|
|
300
337
|
}
|
|
301
338
|
if (await this.deps.probe(this.deps.baseUrl, this.deps.probeTimeoutMs)) {
|
|
@@ -367,7 +404,10 @@ export class MemoryForward {
|
|
|
367
404
|
if (!(await this.deps.probe(this.deps.baseUrl, this.deps.probeTimeoutMs))) {
|
|
368
405
|
return this.settle(
|
|
369
406
|
"failed",
|
|
370
|
-
|
|
407
|
+
(await this.upstreamDetail()) ??
|
|
408
|
+
(this.deps.local
|
|
409
|
+
? "the forward we started is listening but upstream is not answering (the memory container or OpenViking) — r retries"
|
|
410
|
+
: "the forward we started is listening but upstream is not answering (ssh, VM container or OpenViking) — r retries"),
|
|
371
411
|
);
|
|
372
412
|
}
|
|
373
413
|
return this.settle("owned", this.ownedDetail(bound, cfg.binds.length));
|
|
@@ -400,6 +440,15 @@ export class MemoryForward {
|
|
|
400
440
|
return this.inFlight;
|
|
401
441
|
}
|
|
402
442
|
|
|
443
|
+
/** The upstream's own account, when one was wired; total. */
|
|
444
|
+
private async upstreamDetail(): Promise<string | undefined> {
|
|
445
|
+
try {
|
|
446
|
+
return await this.deps.diagnoseUpstream?.();
|
|
447
|
+
} catch {
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
403
452
|
/** A partial bind is a working state, not a failure: loopback alone serves
|
|
404
453
|
* the TUI, and the docker-bridge bind only matters when the companion
|
|
405
454
|
* container is up. Say which, rather than reporting a bare "ok". */
|
package/src/tui/model.ts
CHANGED
|
@@ -3,7 +3,16 @@
|
|
|
3
3
|
// selection movement.
|
|
4
4
|
import type { PrLifecycleState, StatusSnapshot } from "../state.js";
|
|
5
5
|
|
|
6
|
-
export type SnapshotPr = StatusSnapshot["prs"][number]
|
|
6
|
+
export type SnapshotPr = StatusSnapshot["prs"][number] & {
|
|
7
|
+
/** A board-only row: an open PR of the repo the daemon does not track yet
|
|
8
|
+
* (status.json `open_prs`). It is offered for review (`R`); nothing about it
|
|
9
|
+
* is known beyond what the PR list says, so no PR verb but `R` and `g`
|
|
10
|
+
* acts on it. */
|
|
11
|
+
offered?: true;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** The glyph of an offered row: open on GitHub, not reviewed yet. */
|
|
15
|
+
export const OFFERED_GLYPH = "○";
|
|
7
16
|
|
|
8
17
|
export const STATE_GLYPHS: Record<PrLifecycleState, string> = {
|
|
9
18
|
queued: "●",
|
|
@@ -14,7 +23,8 @@ export const STATE_GLYPHS: Record<PrLifecycleState, string> = {
|
|
|
14
23
|
};
|
|
15
24
|
|
|
16
25
|
/** `↻` prefixes the state glyph when a stale recheck is pending. */
|
|
17
|
-
export function glyphFor(pr: Pick<SnapshotPr, "state" | "stale_recheck">): string {
|
|
26
|
+
export function glyphFor(pr: Pick<SnapshotPr, "state" | "stale_recheck" | "offered">): string {
|
|
27
|
+
if (pr.offered) return OFFERED_GLYPH;
|
|
18
28
|
const base = STATE_GLYPHS[pr.state] ?? "?";
|
|
19
29
|
return pr.stale_recheck ? `↻${base}` : base;
|
|
20
30
|
}
|
|
@@ -79,6 +89,30 @@ export function sortPrs(prs: SnapshotPr[]): SnapshotPr[] {
|
|
|
79
89
|
.sort((a, b) => rank(a.state) - rank(b.state) || a.number - b.number);
|
|
80
90
|
}
|
|
81
91
|
|
|
92
|
+
/** The repo's open PRs the daemon does not track (status.json `open_prs`),
|
|
93
|
+
* newest activity first, as offered board rows. A tracked PR is never offered
|
|
94
|
+
* twice: its own row is the one the verbs act on. */
|
|
95
|
+
export function offeredPrs(
|
|
96
|
+
snapshot: Pick<StatusSnapshot, "prs" | "open_prs"> | undefined,
|
|
97
|
+
): SnapshotPr[] {
|
|
98
|
+
const open = snapshot?.open_prs?.prs ?? [];
|
|
99
|
+
const tracked = new Set((snapshot?.prs ?? []).filter(onBoard).map((p) => p.number));
|
|
100
|
+
return open
|
|
101
|
+
.filter((p) => !tracked.has(p.number))
|
|
102
|
+
.slice()
|
|
103
|
+
.sort((a, b) => b.updated_at.localeCompare(a.updated_at) || b.number - a.number)
|
|
104
|
+
.map((p) => ({
|
|
105
|
+
number: p.number,
|
|
106
|
+
state: "tracked" as const,
|
|
107
|
+
title: p.draft ? `[draft] ${p.title}` : p.title,
|
|
108
|
+
head_sha: "",
|
|
109
|
+
stale_recheck: 0,
|
|
110
|
+
author: p.author,
|
|
111
|
+
updated_at: p.updated_at,
|
|
112
|
+
offered: true as const,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
82
116
|
export interface EventLine {
|
|
83
117
|
ts: string; // HH:MM:SS
|
|
84
118
|
text: string;
|
package/src/tui/prpause.ts
CHANGED
|
@@ -20,8 +20,10 @@ import type { SshRunner } from "./remote.js";
|
|
|
20
20
|
import { commandResultArgv, commandWriteArgv, resultReason } from "./unwedge.js";
|
|
21
21
|
import { resolveLayout } from "./layout.js";
|
|
22
22
|
|
|
23
|
-
/** The
|
|
24
|
-
|
|
23
|
+
/** The payloads, read from the daemon's own `Command`: the pause pair, and
|
|
24
|
+
* `review-now` (one PR queued for a human-asked round), which rides the same
|
|
25
|
+
* sealed channel and the same receipt. */
|
|
26
|
+
export type PrLeverCommand = Extract<Command, { command: "pause-pr" | "resume-pr" | "review-now" }>;
|
|
25
27
|
|
|
26
28
|
export interface PrLeverOutcome {
|
|
27
29
|
ok: boolean;
|
|
@@ -132,6 +134,13 @@ export function renderPrLever(
|
|
|
132
134
|
return { lines, code: 1 };
|
|
133
135
|
}
|
|
134
136
|
if (cmd.command === "resume-pr") return { lines: [`✓ ${pr} resumed`], code: 0 };
|
|
137
|
+
if (cmd.command === "review-now")
|
|
138
|
+
return {
|
|
139
|
+
lines: [
|
|
140
|
+
`✓ ${pr} queued for review${out.outcome ? ` (${out.outcome})` : ""} — follow it with \`qcr why ${cmd.pr}\` or on the board`,
|
|
141
|
+
],
|
|
142
|
+
code: 0,
|
|
143
|
+
};
|
|
135
144
|
if (out.issuer !== "operator") {
|
|
136
145
|
return {
|
|
137
146
|
lines: [
|
package/src/tui/remote.ts
CHANGED
|
@@ -337,8 +337,16 @@ export function killInFlightSsh(): number {
|
|
|
337
337
|
return killed;
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
+
/** What the ssh runner answers for an empty host — spawned, `ssh "" …` would
|
|
341
|
+
* die on "Could not resolve hostname" and every surface would print an ssh
|
|
342
|
+
* remedy with no host in it. */
|
|
343
|
+
export const NO_SSH_HOST_STDERR =
|
|
344
|
+
'[tui] ssh_host is empty — run `qcr onboard`, or set [tui] target = "local"';
|
|
345
|
+
|
|
340
346
|
export const realSshRunner: SshRunner = (host, argv, opts) =>
|
|
341
|
-
|
|
347
|
+
host.trim() === ""
|
|
348
|
+
? Promise.resolve({ ok: false, stdout: "", stderr: NO_SSH_HOST_STDERR, code: null })
|
|
349
|
+
: runChild("ssh", ["-o", "BatchMode=yes", host, argv.map(shellQuote).join(" ")], opts);
|
|
342
350
|
|
|
343
351
|
/**
|
|
344
352
|
* The laptop topology's runner — the same argv every builder above
|
package/src/tui/route.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// The TUI's route — which daemon the board reads, and how — resolved ONCE,
|
|
2
|
+
// before the first frame, from config.toml's [tui] block plus three local
|
|
3
|
+
// facts that cost no docker, network or directory walk: the compose
|
|
4
|
+
// project's `.env`, the onboarding checklist, and whether this user can open
|
|
5
|
+
// the Docker socket without sudo.
|
|
6
|
+
//
|
|
7
|
+
// Why a resolution step at all. `[tui]` ships VM-shaped defaults (target
|
|
8
|
+
// "ssh", sudo docker, the VM host's state path), and on one box a fresh
|
|
9
|
+
// install opens the TUI before onboarding's config_fill has written its
|
|
10
|
+
// local values — or with a hand-written config that never names them. Every
|
|
11
|
+
// builder in remote.ts reads the config it is handed, and the transport is
|
|
12
|
+
// still decided by the explicit `target` knob alone (`transportOf`); this
|
|
13
|
+
// module only decides which config that is, and says so once when it departs
|
|
14
|
+
// from the file:
|
|
15
|
+
//
|
|
16
|
+
// * `target = "ssh"` with an EMPTY `ssh_host` cannot reach anything. When
|
|
17
|
+
// this checkout is a compose project (the checklist recorded the local
|
|
18
|
+
// topology, or none was recorded and the project `.env` pins a release)
|
|
19
|
+
// the route is the local stack, said once; otherwise the route is
|
|
20
|
+
// `unconfigured` and every surface shows the one precise remedy instead
|
|
21
|
+
// of an ssh command with no host in it.
|
|
22
|
+
// * On the local route the state dir is the release image's
|
|
23
|
+
// (`/opt/qcr/state`, the compose `qcr-state` volume) whenever the knob
|
|
24
|
+
// still holds the VM default, the daemon's `-daemon-N` container implies
|
|
25
|
+
// its `-memory-N` sidecar for the memory forward, and sudo is dropped
|
|
26
|
+
// when the socket answers this user without it (a non-interactive
|
|
27
|
+
// `sudo docker` behind the alt screen can only fail).
|
|
28
|
+
//
|
|
29
|
+
// An operator's explicit, non-default value always wins: nothing here
|
|
30
|
+
// overwrites a value the file states differently from the shipped default.
|
|
31
|
+
import { accessSync, constants, readFileSync } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { TUI_DEFAULTS, type TuiConfig } from "../config.js";
|
|
34
|
+
import { readChecklist, type ChecklistFile } from "../onboard/engine.js";
|
|
35
|
+
import { CHOICE_KEYS } from "../onboard/choices.js";
|
|
36
|
+
import { NO_SSH_HOST_STDERR } from "./remote.js";
|
|
37
|
+
|
|
38
|
+
/** The release image's state dir inside the daemon container (the compose
|
|
39
|
+
* `qcr-state` volume; src/onboard/steps/ingress.ts CONTAINER_STATE_DIR). */
|
|
40
|
+
export const RELEASE_STATE_DIR = "/opt/qcr/state";
|
|
41
|
+
|
|
42
|
+
/** The bundled compose project's daemon and memory containers
|
|
43
|
+
* (docker-compose.yml `name: quickreview`). */
|
|
44
|
+
export const COMPOSE_DAEMON_CONTAINER = "quickreview-daemon-1";
|
|
45
|
+
export const COMPOSE_MEMORY_CONTAINER = "quickreview-memory-1";
|
|
46
|
+
|
|
47
|
+
/** The legacy daemon container (no `qcr.layout` label): the one container whose
|
|
48
|
+
* in-container state dir is still the VM-shaped default. */
|
|
49
|
+
const LEGACY_CONTAINER = "quickreview-sandbox";
|
|
50
|
+
|
|
51
|
+
export interface RouteFacts {
|
|
52
|
+
/** The compose project directory (this checkout): remedy text names it. */
|
|
53
|
+
projectDir: string;
|
|
54
|
+
/** `<project>/.env`, or null when there is none. */
|
|
55
|
+
projectEnv: string | null;
|
|
56
|
+
/** The onboarding checklist, or null when absent or unreadable. */
|
|
57
|
+
checklist: ChecklistFile | null;
|
|
58
|
+
/** Can this user open the Docker socket without sudo? undefined = no socket
|
|
59
|
+
* found to ask (no local Docker, or a remote DOCKER_HOST). */
|
|
60
|
+
dockerSocketAccess: boolean | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TuiRoute {
|
|
64
|
+
/** The config every TUI remote op is built from. */
|
|
65
|
+
cfg: TuiConfig;
|
|
66
|
+
/** One-time lines for the notice line: where the route departs from the file. */
|
|
67
|
+
notes: string[];
|
|
68
|
+
/** Set when nothing can be reached: the precise reason and remedy, shown in
|
|
69
|
+
* place of any ssh diagnosis. */
|
|
70
|
+
unconfigured?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The precise message for `target = "ssh"` with no host and no local stack —
|
|
74
|
+
* the same words the ssh runner answers an empty host with. */
|
|
75
|
+
export const EMPTY_SSH_HOST = NO_SSH_HOST_STDERR;
|
|
76
|
+
|
|
77
|
+
/** `QCR_VERSION=` from a project `.env` — "" when absent. */
|
|
78
|
+
export function pinnedVersion(envText: string | null): string {
|
|
79
|
+
if (envText === null) return "";
|
|
80
|
+
const m = /^QCR_VERSION=(.*)$/m.exec(envText);
|
|
81
|
+
return m ? (m[1] ?? "").trim().replace(/^["']|["']$/g, "") : "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The recorded topology, or null when the checklist recorded none. */
|
|
85
|
+
function recordedTopology(checklist: ChecklistFile | null): "local" | "vm" | null {
|
|
86
|
+
const raw = checklist?.choices[CHOICE_KEYS.topology];
|
|
87
|
+
return raw === "local" || raw === "vm" ? raw : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The `-memory-N` sidecar a compose `-daemon-N` container implies, or "". */
|
|
91
|
+
export function composeMemoryFor(container: string): string {
|
|
92
|
+
const m = /^(.+)-daemon-(\d+)$/.exec(container);
|
|
93
|
+
return m ? `${m[1]}-memory-${m[2]}` : "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Pure: the effective [tui] config for these facts. */
|
|
97
|
+
export function resolveTuiRoute(file: TuiConfig, facts: RouteFacts): TuiRoute {
|
|
98
|
+
const notes: string[] = [];
|
|
99
|
+
let cfg: TuiConfig = { ...file };
|
|
100
|
+
if (cfg.target === "ssh" && cfg.ssh_host.trim() === "") {
|
|
101
|
+
const topology = recordedTopology(facts.checklist);
|
|
102
|
+
const composeProject = pinnedVersion(facts.projectEnv) !== "";
|
|
103
|
+
if (topology === "local" || (topology === null && composeProject)) {
|
|
104
|
+
cfg = {
|
|
105
|
+
...cfg,
|
|
106
|
+
target: "local",
|
|
107
|
+
container:
|
|
108
|
+
cfg.container === TUI_DEFAULTS.container ? COMPOSE_DAEMON_CONTAINER : cfg.container,
|
|
109
|
+
};
|
|
110
|
+
notes.push(
|
|
111
|
+
`[tui] target is "ssh" with no ssh_host — reading the local compose stack in ${facts.projectDir} instead ` +
|
|
112
|
+
'(run `qcr onboard`, or set [tui] target = "local", to make it explicit)',
|
|
113
|
+
);
|
|
114
|
+
} else {
|
|
115
|
+
return {
|
|
116
|
+
cfg,
|
|
117
|
+
notes,
|
|
118
|
+
unconfigured:
|
|
119
|
+
topology === "vm"
|
|
120
|
+
? "[tui] ssh_host is empty — the VM route's ssh alias is written by onboarding: run `qcr onboard`"
|
|
121
|
+
: EMPTY_SSH_HOST,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (cfg.target !== "local") return { cfg, notes };
|
|
126
|
+
if (cfg.remote_state_dir === TUI_DEFAULTS.remote_state_dir && cfg.container !== LEGACY_CONTAINER)
|
|
127
|
+
cfg = { ...cfg, remote_state_dir: RELEASE_STATE_DIR };
|
|
128
|
+
if ((cfg.memory_container ?? "").trim() === "") {
|
|
129
|
+
const memory = composeMemoryFor(cfg.container);
|
|
130
|
+
if (memory !== "") cfg = { ...cfg, memory_container: memory };
|
|
131
|
+
}
|
|
132
|
+
if (cfg.sudo_docker && facts.dockerSocketAccess === true) cfg = { ...cfg, sudo_docker: false };
|
|
133
|
+
return { cfg, notes };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The Docker socket this process would talk to: `DOCKER_HOST` when it is a
|
|
137
|
+
* unix socket, the default path otherwise; undefined for a remote daemon. */
|
|
138
|
+
export function dockerSocketPath(env: NodeJS.ProcessEnv): string | undefined {
|
|
139
|
+
const host = (env["DOCKER_HOST"] ?? "").trim();
|
|
140
|
+
if (host === "") return "/var/run/docker.sock";
|
|
141
|
+
return host.startsWith("unix://") ? host.slice("unix://".length) : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** One `access(2)` on the socket — no docker call, no walk. */
|
|
145
|
+
export function probeSocketAccess(
|
|
146
|
+
path: string | undefined,
|
|
147
|
+
access: (p: string, mode: number) => void = accessSync,
|
|
148
|
+
): boolean | undefined {
|
|
149
|
+
if (path === undefined) return undefined;
|
|
150
|
+
try {
|
|
151
|
+
access(path, constants.R_OK | constants.W_OK);
|
|
152
|
+
return true;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
155
|
+
return code === "EACCES" || code === "EPERM" ? false : undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The facts, read from this checkout: two small files and one access(2). */
|
|
160
|
+
export function readRouteFacts(root: string, env: NodeJS.ProcessEnv = process.env): RouteFacts {
|
|
161
|
+
let projectEnv: string | null = null;
|
|
162
|
+
try {
|
|
163
|
+
projectEnv = readFileSync(join(root, ".env"), "utf8");
|
|
164
|
+
} catch {
|
|
165
|
+
projectEnv = null;
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
projectDir: root,
|
|
169
|
+
projectEnv,
|
|
170
|
+
checklist: readChecklist(join(root, "state")),
|
|
171
|
+
dockerSocketAccess: probeSocketAccess(dockerSocketPath(env)),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Is onboarding finished? Read from the checklist file the wizard keeps
|
|
2
|
+
// (src/onboard/engine.ts), so a TUI opened halfway through `qcr onboard` says
|
|
3
|
+
// so on its first frame instead of troubleshooting a daemon nobody has started
|
|
4
|
+
// yet.
|
|
5
|
+
//
|
|
6
|
+
// Only a checklist that EXISTS and is not complete counts: an install that
|
|
7
|
+
// never ran the wizard (a hand-written config, the VM operator's own setup)
|
|
8
|
+
// has no checklist and gets no banner. The next step is the first enabled step,
|
|
9
|
+
// in the wizard's own order, that the file does not record as done — the same
|
|
10
|
+
// predicates the engine applies, so the TUI cannot name a step the wizard would
|
|
11
|
+
// skip.
|
|
12
|
+
import { type ChecklistFile } from "../onboard/engine.js";
|
|
13
|
+
|
|
14
|
+
export interface SetupState {
|
|
15
|
+
/** The title of the first pending step ("the final verification" when only
|
|
16
|
+
* finish is left), or undefined when it could not be computed. */
|
|
17
|
+
next: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The one sentence every surface uses. */
|
|
21
|
+
export function setupLine(s: SetupState): string {
|
|
22
|
+
return `setup not finished — run \`qcr onboard\` to continue${s.next ? ` (next: ${s.next})` : ""}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Complete now: finished once and not reopened since (engine.ts `completeNow`). */
|
|
26
|
+
function completeNow(file: ChecklistFile): boolean {
|
|
27
|
+
return typeof file.completed_at === "string" && !file.reopened_at;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** undefined = nothing to say (no checklist, or onboarding is complete). */
|
|
31
|
+
export async function setupStateOf(
|
|
32
|
+
checklist: ChecklistFile | null,
|
|
33
|
+
): Promise<SetupState | undefined> {
|
|
34
|
+
if (checklist === null || completeNow(checklist)) return undefined;
|
|
35
|
+
let next: string | undefined;
|
|
36
|
+
try {
|
|
37
|
+
const { defaultSteps } = await import("../onboard/steps/index.js");
|
|
38
|
+
const steps = [...defaultSteps()].sort((a, b) => a.order - b.order);
|
|
39
|
+
const pending = steps.find(
|
|
40
|
+
(s) =>
|
|
41
|
+
!s.optional &&
|
|
42
|
+
(s.enabled ? s.enabled(checklist.choices) : true) &&
|
|
43
|
+
checklist.steps[s.id]?.status !== "done",
|
|
44
|
+
);
|
|
45
|
+
next = pending?.title ?? "the final verification";
|
|
46
|
+
} catch {
|
|
47
|
+
// The step registry could not be loaded: the banner still says setup is
|
|
48
|
+
// unfinished, without naming a step it could not compute.
|
|
49
|
+
next = undefined;
|
|
50
|
+
}
|
|
51
|
+
return { next };
|
|
52
|
+
}
|
package/src/tui/sshtriage.ts
CHANGED
|
@@ -25,13 +25,22 @@ import { existsSync } from "node:fs";
|
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import type { TuiConfig } from "../config.js";
|
|
27
27
|
import { masterCheckArgv } from "./connect.js";
|
|
28
|
-
import { shellQuote, transportOf } from "./remote.js";
|
|
28
|
+
import { NO_SSH_HOST_STDERR, shellQuote, transportOf } from "./remote.js";
|
|
29
29
|
|
|
30
30
|
export type ConnectionClass =
|
|
31
31
|
/** The VM answers — master alive, or a fresh connection succeeded. */
|
|
32
32
|
| "ok"
|
|
33
|
-
/** `[tui] target = "local"`: there is no ssh anywhere
|
|
33
|
+
/** `[tui] target = "local"`: there is no ssh anywhere, and the daemon
|
|
34
|
+
* container is running on this machine. */
|
|
34
35
|
| "local"
|
|
36
|
+
/** Local route: Docker answers, but the daemon container is absent or not
|
|
37
|
+
* running (the stack is not up yet, or it stopped). */
|
|
38
|
+
| "local-down"
|
|
39
|
+
/** Local route: the local Docker itself did not answer this user. */
|
|
40
|
+
| "local-docker"
|
|
41
|
+
/** Nothing to connect to: `target = "ssh"` with no `ssh_host` (onboarding
|
|
42
|
+
* has not written the route yet). No ssh is ever spawned for it. */
|
|
43
|
+
| "unconfigured"
|
|
35
44
|
/** The agent cannot offer the key ssh wants for this host. */
|
|
36
45
|
| "no-agent-key"
|
|
37
46
|
/** A ControlMaster socket is there and does not answer. */
|
|
@@ -413,6 +422,112 @@ export interface TriageDeps {
|
|
|
413
422
|
run?: ProbeRunner;
|
|
414
423
|
exists?: (path: string) => boolean;
|
|
415
424
|
home?: string;
|
|
425
|
+
/** The compose project directory, for the local route's remedy line. */
|
|
426
|
+
projectDir?: string;
|
|
427
|
+
/** The route resolver's reason nothing can be reached (src/tui/route.ts);
|
|
428
|
+
* defaults to the empty-ssh_host sentence. */
|
|
429
|
+
unconfigured?: string;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Is this a local-route diagnosis (no ssh anywhere)? */
|
|
433
|
+
export function isLocalDiagnosis(d: Pick<ConnectionDiagnosis, "kind">): boolean {
|
|
434
|
+
return d.kind === "local" || d.kind === "local-down" || d.kind === "local-docker";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** A diagnosis under which the daemon can answer: the board's "connected". */
|
|
438
|
+
export function connectionUsable(d: Pick<ConnectionDiagnosis, "kind">): boolean {
|
|
439
|
+
return d.kind === "ok" || d.kind === "local";
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** `docker inspect` of the daemon container's state, for the local route.
|
|
443
|
+
* `sudo -n` when sudo is configured: a password prompt behind the alt screen
|
|
444
|
+
* is a hang, so a sudo that would ask fails instead. */
|
|
445
|
+
export function localDaemonProbeArgv(cfg: TuiConfig): { cmd: string; argv: string[] } {
|
|
446
|
+
const inspect = ["inspect", "-f", "{{.State.Status}}", cfg.container];
|
|
447
|
+
return cfg.sudo_docker
|
|
448
|
+
? { cmd: "sudo", argv: ["-n", "docker", ...inspect] }
|
|
449
|
+
: { cmd: "docker", argv: inspect };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** How to bring the stack up from here. */
|
|
453
|
+
export function composeUpRemedy(projectDir: string | undefined, container: string): string {
|
|
454
|
+
return projectDir
|
|
455
|
+
? `docker compose --project-directory ${shellQuote(projectDir)} up -d`
|
|
456
|
+
: `docker start ${shellQuote(container)}`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Pure: the local route's diagnosis from one `docker inspect`. */
|
|
460
|
+
export function classifyLocal(
|
|
461
|
+
cfg: TuiConfig,
|
|
462
|
+
res: ProbeResult,
|
|
463
|
+
projectDir?: string,
|
|
464
|
+
): ConnectionDiagnosis {
|
|
465
|
+
const c = cfg.container;
|
|
466
|
+
const out = res.stdout.trim();
|
|
467
|
+
const evidence = firstLine(res.stderr);
|
|
468
|
+
const detail = evidence ? { detail: evidence } : {};
|
|
469
|
+
if (res.status === 0 && out === "running") {
|
|
470
|
+
return {
|
|
471
|
+
kind: "local",
|
|
472
|
+
cause: `local docker — the daemon container ${c} is running on this machine (no ssh)`,
|
|
473
|
+
remedy: "",
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
if (res.status === 0) {
|
|
477
|
+
return {
|
|
478
|
+
kind: "local-down",
|
|
479
|
+
cause: `the daemon container ${c} is ${out || "not running"} — start it`,
|
|
480
|
+
remedy: composeUpRemedy(projectDir, c),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
if (res.timedOut) {
|
|
484
|
+
return {
|
|
485
|
+
kind: "local-docker",
|
|
486
|
+
cause: "local Docker did not answer in time",
|
|
487
|
+
remedy: "docker info",
|
|
488
|
+
...detail,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
const text = `${res.stderr}
|
|
492
|
+
${res.stdout}`;
|
|
493
|
+
if (/no such (object|container)/i.test(text)) {
|
|
494
|
+
return {
|
|
495
|
+
kind: "local-down",
|
|
496
|
+
cause: `no daemon container ${c} on this machine — the compose stack is not up (it starts at onboarding's bring-up step)`,
|
|
497
|
+
remedy: composeUpRemedy(projectDir, c),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
if (/a password is required|a terminal is required/i.test(text)) {
|
|
501
|
+
return {
|
|
502
|
+
kind: "local-docker",
|
|
503
|
+
cause: "[tui] sudo_docker is on and sudo wants a password the TUI cannot type",
|
|
504
|
+
remedy:
|
|
505
|
+
'sudo usermod -aG docker "$USER" # then log in again, and set [tui] sudo_docker = false',
|
|
506
|
+
...detail,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
if (/permission denied/i.test(text)) {
|
|
510
|
+
return {
|
|
511
|
+
kind: "local-docker",
|
|
512
|
+
cause: "this user cannot open the local Docker socket",
|
|
513
|
+
remedy: 'sudo usermod -aG docker "$USER" # then log in again',
|
|
514
|
+
...detail,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
if (/ENOENT|not found|no such file/i.test(text) && !/daemon/i.test(text)) {
|
|
518
|
+
return {
|
|
519
|
+
kind: "local-docker",
|
|
520
|
+
cause: "there is no docker command on this machine",
|
|
521
|
+
remedy: "docker --version",
|
|
522
|
+
...detail,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
kind: "local-docker",
|
|
527
|
+
cause: `local Docker is not answering — ${evidence || "no error text"}`,
|
|
528
|
+
remedy: "docker info",
|
|
529
|
+
...detail,
|
|
530
|
+
};
|
|
416
531
|
}
|
|
417
532
|
|
|
418
533
|
const CONFIG_TIMEOUT_MS = 5_000;
|
|
@@ -458,14 +573,20 @@ export async function triageConnection(
|
|
|
458
573
|
cfg: TuiConfig,
|
|
459
574
|
deps: TriageDeps = {},
|
|
460
575
|
): Promise<ConnectionDiagnosis> {
|
|
576
|
+
const run = deps.run ?? realProbeRunner;
|
|
461
577
|
if (transportOf(cfg) === "local") {
|
|
578
|
+
// No ssh anywhere: the one probe is local docker's own answer about the
|
|
579
|
+
// daemon container.
|
|
580
|
+
const probe = localDaemonProbeArgv(cfg);
|
|
581
|
+
return classifyLocal(cfg, await run(probe.cmd, probe.argv, CONFIG_TIMEOUT_MS), deps.projectDir);
|
|
582
|
+
}
|
|
583
|
+
if (cfg.ssh_host.trim() === "") {
|
|
462
584
|
return {
|
|
463
|
-
kind: "
|
|
464
|
-
cause:
|
|
465
|
-
remedy: "",
|
|
585
|
+
kind: "unconfigured",
|
|
586
|
+
cause: deps.unconfigured ?? NO_SSH_HOST_STDERR,
|
|
587
|
+
remedy: "qcr onboard",
|
|
466
588
|
};
|
|
467
589
|
}
|
|
468
|
-
const run = deps.run ?? realProbeRunner;
|
|
469
590
|
const exists = deps.exists ?? existsSync;
|
|
470
591
|
const home = deps.home ?? homedir();
|
|
471
592
|
const host = cfg.ssh_host;
|
package/src/tui/theme.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// legend — a glance at the color tells the state without reading. Verdicts
|
|
4
4
|
// and anomalies get their own fixed colors.
|
|
5
5
|
import { ACCENT, MUTED } from "./design.js";
|
|
6
|
-
import { STATE_GLYPHS } from "./model.js";
|
|
6
|
+
import { OFFERED_GLYPH, STATE_GLYPHS } from "./model.js";
|
|
7
7
|
import { ESC, rgb } from "./paint.js";
|
|
8
8
|
|
|
9
9
|
export const FG = {
|
|
@@ -78,6 +78,7 @@ export function legendLine(): string {
|
|
|
78
78
|
[STATE_GLYPHS.queued, "queued", FG.queued],
|
|
79
79
|
[STATE_GLYPHS.awaiting_author, "author", FG.awaiting_author],
|
|
80
80
|
[STATE_GLYPHS.tracked, "tracked", FG.tracked],
|
|
81
|
+
[OFFERED_GLYPH, "not reviewed", FG.dim],
|
|
81
82
|
["↻", "stale", FG.accent],
|
|
82
83
|
];
|
|
83
84
|
return parts
|
package/src/tui/unwedge.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type { App } from "./app.js";
|
|
|
26
26
|
import { sanitizeText } from "./paint.js";
|
|
27
27
|
import type { SshRunner } from "./remote.js";
|
|
28
28
|
import { remoteSandboxRoot, resolveLayout } from "./layout.js";
|
|
29
|
+
import { operatorCommandWriteScriptAt } from "../operatorwrite.js";
|
|
29
30
|
|
|
30
31
|
/** The commands this channel may produce. Deliberately a closed set: the
|
|
31
32
|
* daemon's whitelist is the authority, and this is the narrower subset that
|
|
@@ -94,20 +95,7 @@ export function commandFileName(now: Date, seq: number, client?: string): string
|
|
|
94
95
|
* (`tests/levers.test.ts`).
|
|
95
96
|
*/
|
|
96
97
|
export function operatorCommandWriteScript(cfg: TuiConfig, name: string): string {
|
|
97
|
-
|
|
98
|
-
const dir = operatorCommandDirFor(cfg);
|
|
99
|
-
const tmp = `.${name}.tmp`;
|
|
100
|
-
return (
|
|
101
|
-
`[ -d '${root}' ] || { echo 'daemon commands dir ${root} missing - is the daemon running?' >&2; exit 1; }; ` +
|
|
102
|
-
`mkdir -p '${dir}' || exit 1; ` +
|
|
103
|
-
`[ ! -L '${dir}' ] || { echo '${dir} is a symlink - refusing to write the operator command' >&2; exit 1; }; ` +
|
|
104
|
-
`R=$(cd -P '${root}' && pwd -P) || exit 1; ` +
|
|
105
|
-
`cd -P '${dir}' || exit 1; ` +
|
|
106
|
-
`[ "$(pwd -P)" = "$R/operator" ] || { echo '${dir} was replaced while writing - refusing to write the operator command' >&2; exit 1; }; ` +
|
|
107
|
-
`chown -h 0:0 . && chmod 0755 . || exit 1; ` +
|
|
108
|
-
`find . -maxdepth 1 -name '*.json' -mmin +1440 -delete 2>/dev/null; ` +
|
|
109
|
-
`rm -f '${tmp}' '${name}' && cat > '${tmp}' && chmod 0644 '${tmp}' && mv '${tmp}' '${name}'`
|
|
110
|
-
);
|
|
98
|
+
return operatorCommandWriteScriptAt(commandDirFor(cfg), name);
|
|
111
99
|
}
|
|
112
100
|
|
|
113
101
|
/** argv for the atomic command write. The payload rides STDIN, never the
|