@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
@@ -41,7 +41,7 @@ import {
41
41
  stateHome,
42
42
  type InstallEnv,
43
43
  } from "../install.js";
44
- import type { CompanionConfig } from "../config.js";
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.cfg.image, COMPANION_MODE);
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.cfg.image]);
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.cfg.image} image (first run — takes a few minutes)…`);
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.cfg.image, built, stamp), {
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.cfg.image} failed: ${errText(res)}`);
977
- onProgress?.(`${this.cfg.image} image built`);
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: build the image rather than failing on its absence.
1270
- if (!(await this.imageExists())) await this.buildImage(onProgress);
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.cfg.image} failed: ${errText(created)}`);
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
 
@@ -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)`,
@@ -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 { sortPrs, type SnapshotPr } from "./model.js";
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
- return applyBoardFilter(sortPrs(view.snapshot?.prs ?? []), view.boardFilter);
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(tok: ReadToken, repo: string): string {
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
- "# Minted on the host by qcr: a READ-ONLY GitHub App installation token\n" +
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 App secrets dir (`app_id`, `app.pem`). Read here, on the
258
- * host; nothing from it is ever written into `dir`. */
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 reconnect ssh (GCP passphrase, once) · r refresh ·
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 loaded = deps.config ?? loadConfig();
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
- const progress = new StartupProgress(undefined, {
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
- ...(!socketMode && transportOf(cfg) === "local"
719
- ? {
720
- offReason: async () => ((await dockerUp()) ? undefined : MEMORY_OFF_NO_DOCKER),
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
- if (d.kind === "ok" || d.kind === "local") progress.done("ssh", d.cause);
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(d.kind === "ok" || d.kind === "local" ? `✓ ${d.cause}` : `✖ ${diagnosisLine(d)}`);
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("checking the connection to the VM…");
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.fail(
1443
- "poll",
1444
- app.view.connection ? diagnosisLine(app.view.connection) : "status.json unreadable",
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.