@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.
Files changed (225) hide show
  1. package/README.md +35 -19
  2. package/config.example.toml +41 -9
  3. package/dist/extensions/cli/login-route.js +5 -0
  4. package/dist/extensions/cli/login-route.js.map +1 -0
  5. package/dist/extensions/login-route.js +180 -0
  6. package/dist/extensions/login-route.js.map +1 -0
  7. package/dist/extensions/onboarding.js +29 -6
  8. package/dist/extensions/onboarding.js.map +1 -1
  9. package/dist/src/ciretry.js +2 -1
  10. package/dist/src/ciretry.js.map +1 -1
  11. package/dist/src/cli.js +43 -12
  12. package/dist/src/cli.js.map +1 -1
  13. package/dist/src/cliops.js +3 -2
  14. package/dist/src/cliops.js.map +1 -1
  15. package/dist/src/commands.js +25 -2
  16. package/dist/src/commands.js.map +1 -1
  17. package/dist/src/config.js +16 -2
  18. package/dist/src/config.js.map +1 -1
  19. package/dist/src/daemon.js +24 -8
  20. package/dist/src/daemon.js.map +1 -1
  21. package/dist/src/debugroundpi.js +5 -1
  22. package/dist/src/debugroundpi.js.map +1 -1
  23. package/dist/src/dispatch.js +5 -4
  24. package/dist/src/dispatch.js.map +1 -1
  25. package/dist/src/engine/debugpi.js +17 -5
  26. package/dist/src/engine/debugpi.js.map +1 -1
  27. package/dist/src/execboundary.js +10 -3
  28. package/dist/src/execboundary.js.map +1 -1
  29. package/dist/src/github.js +51 -16
  30. package/dist/src/github.js.map +1 -1
  31. package/dist/src/githubtoken.js +346 -0
  32. package/dist/src/githubtoken.js.map +1 -0
  33. package/dist/src/loginhint.js +8 -3
  34. package/dist/src/loginhint.js.map +1 -1
  35. package/dist/src/loginrefresh/pirefresh.js +3 -0
  36. package/dist/src/loginrefresh/pirefresh.js.map +1 -1
  37. package/dist/src/npmverbs.js +4 -2
  38. package/dist/src/npmverbs.js.map +1 -1
  39. package/dist/src/onboard/choices.js +29 -1
  40. package/dist/src/onboard/choices.js.map +1 -1
  41. package/dist/src/onboard/engine.js +8 -23
  42. package/dist/src/onboard/engine.js.map +1 -1
  43. package/dist/src/onboard/launch.js +12 -20
  44. package/dist/src/onboard/launch.js.map +1 -1
  45. package/dist/src/onboard/pipin.js +22 -0
  46. package/dist/src/onboard/pipin.js.map +1 -0
  47. package/dist/src/onboard/stepforward.js +41 -0
  48. package/dist/src/onboard/stepforward.js.map +1 -0
  49. package/dist/src/onboard/steps/appslug.js +4 -1
  50. package/dist/src/onboard/steps/appslug.js.map +1 -1
  51. package/dist/src/onboard/steps/compose.js +29 -0
  52. package/dist/src/onboard/steps/compose.js.map +1 -1
  53. package/dist/src/onboard/steps/configfill.js +102 -32
  54. package/dist/src/onboard/steps/configfill.js.map +1 -1
  55. package/dist/src/onboard/steps/githubaccount.js +595 -0
  56. package/dist/src/onboard/steps/githubaccount.js.map +1 -0
  57. package/dist/src/onboard/steps/githubapp.js +65 -7
  58. package/dist/src/onboard/steps/githubapp.js.map +1 -1
  59. package/dist/src/onboard/steps/golive.js +2 -0
  60. package/dist/src/onboard/steps/golive.js.map +1 -1
  61. package/dist/src/onboard/steps/index.js +11 -0
  62. package/dist/src/onboard/steps/index.js.map +1 -1
  63. package/dist/src/onboard/steps/ingress.js +80 -25
  64. package/dist/src/onboard/steps/ingress.js.map +1 -1
  65. package/dist/src/onboard/steps/llmauth.js +103 -30
  66. package/dist/src/onboard/steps/llmauth.js.map +1 -1
  67. package/dist/src/onboard/steps/memory.js +53 -1
  68. package/dist/src/onboard/steps/memory.js.map +1 -1
  69. package/dist/src/onboard/steps/proof.js +366 -56
  70. package/dist/src/onboard/steps/proof.js.map +1 -1
  71. package/dist/src/onboard/steps/topology.js +6 -6
  72. package/dist/src/onboard/steps/topology.js.map +1 -1
  73. package/dist/src/onboard/steps/transport.js +9 -1
  74. package/dist/src/onboard/steps/transport.js.map +1 -1
  75. package/dist/src/onboard/steps/tuiroute.js +102 -0
  76. package/dist/src/onboard/steps/tuiroute.js.map +1 -0
  77. package/dist/src/onboard/steps/verifyup.js +17 -1
  78. package/dist/src/onboard/steps/verifyup.js.map +1 -1
  79. package/dist/src/onboard/toolnames.js +30 -0
  80. package/dist/src/onboard/toolnames.js.map +1 -0
  81. package/dist/src/operatorwrite.js +23 -0
  82. package/dist/src/operatorwrite.js.map +1 -0
  83. package/dist/src/outcomes.js +4 -1
  84. package/dist/src/outcomes.js.map +1 -1
  85. package/dist/src/posting-journal.js +2 -1
  86. package/dist/src/posting-journal.js.map +1 -1
  87. package/dist/src/posting-outbox.js +6 -2
  88. package/dist/src/posting-outbox.js.map +1 -1
  89. package/dist/src/posting-receipt.js +14 -1
  90. package/dist/src/posting-receipt.js.map +1 -1
  91. package/dist/src/recheck.js +2 -1
  92. package/dist/src/recheck.js.map +1 -1
  93. package/dist/src/reconcile.js +22 -2
  94. package/dist/src/reconcile.js.map +1 -1
  95. package/dist/src/reviewhistory.js +32 -6
  96. package/dist/src/reviewhistory.js.map +1 -1
  97. package/dist/src/reviewnow.js +45 -0
  98. package/dist/src/reviewnow.js.map +1 -0
  99. package/dist/src/state.js.map +1 -1
  100. package/dist/src/statemachine.js +4 -1
  101. package/dist/src/statemachine.js.map +1 -1
  102. package/dist/src/takeoverpi.js +13 -0
  103. package/dist/src/takeoverpi.js.map +1 -1
  104. package/dist/src/tui/app.js +49 -0
  105. package/dist/src/tui/app.js.map +1 -1
  106. package/dist/src/tui/board.js +88 -12
  107. package/dist/src/tui/board.js.map +1 -1
  108. package/dist/src/tui/cmdbar.js +4 -1
  109. package/dist/src/tui/cmdbar.js.map +1 -1
  110. package/dist/src/tui/companion.js +74 -10
  111. package/dist/src/tui/companion.js.map +1 -1
  112. package/dist/src/tui/connect.js +7 -1
  113. package/dist/src/tui/connect.js.map +1 -1
  114. package/dist/src/tui/filters.js +5 -2
  115. package/dist/src/tui/filters.js.map +1 -1
  116. package/dist/src/tui/ghread.js +61 -5
  117. package/dist/src/tui/ghread.js.map +1 -1
  118. package/dist/src/tui/index.js +106 -20
  119. package/dist/src/tui/index.js.map +1 -1
  120. package/dist/src/tui/memforward.js +39 -2
  121. package/dist/src/tui/memforward.js.map +1 -1
  122. package/dist/src/tui/model.js +25 -0
  123. package/dist/src/tui/model.js.map +1 -1
  124. package/dist/src/tui/prpause.js +7 -0
  125. package/dist/src/tui/prpause.js.map +1 -1
  126. package/dist/src/tui/remote.js +7 -1
  127. package/dist/src/tui/remote.js.map +1 -1
  128. package/dist/src/tui/route.js +143 -0
  129. package/dist/src/tui/route.js.map +1 -0
  130. package/dist/src/tui/setupstate.js +29 -0
  131. package/dist/src/tui/setupstate.js.map +1 -0
  132. package/dist/src/tui/sshtriage.js +102 -5
  133. package/dist/src/tui/sshtriage.js.map +1 -1
  134. package/dist/src/tui/theme.js +2 -1
  135. package/dist/src/tui/theme.js.map +1 -1
  136. package/dist/src/tui/unwedge.js +2 -12
  137. package/dist/src/tui/unwedge.js.map +1 -1
  138. package/dist/src/verdicts.js +17 -5
  139. package/dist/src/verdicts.js.map +1 -1
  140. package/dist/src/version.js +1 -1
  141. package/dist/src/version.js.map +1 -1
  142. package/dist/src/webhook.js +12 -1
  143. package/dist/src/webhook.js.map +1 -1
  144. package/dist/src/wire.js +28 -6
  145. package/dist/src/wire.js.map +1 -1
  146. package/dist/src/workerworld.js +7 -1
  147. package/dist/src/workerworld.js.map +1 -1
  148. package/docker/companion/entrypoint.sh +20 -1
  149. package/docker/entrypoint.sh +17 -0
  150. package/extensions/cli/login-route.ts +10 -0
  151. package/extensions/login-route.ts +220 -0
  152. package/extensions/onboarding.ts +35 -6
  153. package/package.json +2 -2
  154. package/prompts/onboarding.md +57 -18
  155. package/scripts/mint-token.sh +25 -4
  156. package/src/ciretry.ts +2 -1
  157. package/src/cli.ts +49 -14
  158. package/src/cliops.ts +3 -2
  159. package/src/commands.ts +28 -2
  160. package/src/config.ts +24 -4
  161. package/src/daemon.ts +32 -7
  162. package/src/debugroundpi.ts +11 -1
  163. package/src/dispatch.ts +5 -4
  164. package/src/engine/debugpi.ts +17 -5
  165. package/src/execboundary.ts +16 -4
  166. package/src/github.ts +57 -15
  167. package/src/githubtoken.ts +435 -0
  168. package/src/loginhint.ts +8 -3
  169. package/src/loginrefresh/pirefresh.ts +3 -0
  170. package/src/npmverbs.ts +4 -2
  171. package/src/onboard/choices.ts +34 -1
  172. package/src/onboard/engine.ts +22 -3
  173. package/src/onboard/launch.ts +17 -29
  174. package/src/onboard/pipin.ts +31 -0
  175. package/src/onboard/stepforward.ts +61 -0
  176. package/src/onboard/steps/appslug.ts +4 -1
  177. package/src/onboard/steps/compose.ts +31 -0
  178. package/src/onboard/steps/configfill.ts +121 -33
  179. package/src/onboard/steps/githubaccount.ts +716 -0
  180. package/src/onboard/steps/githubapp.ts +92 -8
  181. package/src/onboard/steps/golive.ts +2 -0
  182. package/src/onboard/steps/index.ts +11 -0
  183. package/src/onboard/steps/ingress.ts +98 -31
  184. package/src/onboard/steps/llmauth.ts +114 -29
  185. package/src/onboard/steps/memory.ts +58 -1
  186. package/src/onboard/steps/proof.ts +473 -55
  187. package/src/onboard/steps/topology.ts +7 -6
  188. package/src/onboard/steps/transport.ts +15 -0
  189. package/src/onboard/steps/tuiroute.ts +119 -0
  190. package/src/onboard/steps/verifyup.ts +20 -1
  191. package/src/onboard/toolnames.ts +34 -0
  192. package/src/operatorwrite.ts +25 -0
  193. package/src/outcomes.ts +8 -1
  194. package/src/posting-journal.ts +6 -1
  195. package/src/posting-outbox.ts +10 -2
  196. package/src/posting-receipt.ts +17 -0
  197. package/src/recheck.ts +2 -1
  198. package/src/reconcile.ts +29 -3
  199. package/src/reviewhistory.ts +20 -6
  200. package/src/reviewnow.ts +76 -0
  201. package/src/state.ts +18 -0
  202. package/src/statemachine.ts +4 -1
  203. package/src/takeoverpi.ts +13 -0
  204. package/src/tui/app.ts +47 -0
  205. package/src/tui/board.ts +111 -14
  206. package/src/tui/cmdbar.ts +4 -1
  207. package/src/tui/companion.ts +78 -10
  208. package/src/tui/connect.ts +7 -1
  209. package/src/tui/filters.ts +7 -3
  210. package/src/tui/ghread.ts +74 -7
  211. package/src/tui/index.ts +114 -23
  212. package/src/tui/memforward.ts +52 -3
  213. package/src/tui/model.ts +36 -2
  214. package/src/tui/prpause.ts +11 -2
  215. package/src/tui/remote.ts +9 -1
  216. package/src/tui/route.ts +173 -0
  217. package/src/tui/setupstate.ts +52 -0
  218. package/src/tui/sshtriage.ts +127 -6
  219. package/src/tui/theme.ts +2 -1
  220. package/src/tui/unwedge.ts +2 -14
  221. package/src/verdicts.ts +23 -5
  222. package/src/version.ts +1 -1
  223. package/src/webhook.ts +10 -1
  224. package/src/wire.ts +36 -7
  225. package/src/workerworld.ts +7 -1
@@ -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
- : "the forward we started stopped answering — r retries",
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
- "the forward we started is listening but upstream is not answering (ssh, VM container or OpenViking) — r retries",
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;
@@ -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 two payloads, read from the daemon's own `Command`. */
24
- export type PrLeverCommand = Extract<Command, { command: "pause-pr" | "resume-pr" }>;
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
- runChild("ssh", ["-o", "BatchMode=yes", host, argv.map(shellQuote).join(" ")], opts);
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
@@ -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
+ }
@@ -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: "local",
464
- cause: "local transport — the daemon runs on this machine, no ssh involved",
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
@@ -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
- const root = commandDirFor(cfg);
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