@cat-factory/app 0.261.0 → 0.261.2

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 (36) hide show
  1. package/app/components/foundational/FoundationalServiceManager.vue +1 -1
  2. package/app/components/fragments/FragmentLibraryManager.vue +1 -1
  3. package/app/components/layout/BoardTopOverlays.vue +6 -0
  4. package/app/components/layout/GitHubPatPermissionsBanner.vue +224 -0
  5. package/app/components/settings/ConnectionTestVerdict.vue +62 -0
  6. package/app/components/settings/InfraHandlersConfigurator.logic.spec.ts +64 -0
  7. package/app/components/settings/InfraHandlersConfigurator.logic.ts +41 -0
  8. package/app/components/settings/InfraHandlersConfigurator.vue +36 -9
  9. package/app/components/settings/KubernetesEngineForm.vue +24 -7
  10. package/app/components/settings/KubernetesEnvironmentForm.vue +20 -7
  11. package/app/components/settings/ProviderConnectionTab.vue +3 -7
  12. package/app/components/settings/ProviderManifestEditor.vue +3 -7
  13. package/app/components/skills/SkillLibraryManager.vue +1 -1
  14. package/app/composables/api/github.ts +7 -0
  15. package/app/composables/useServiceAccountTokenProblem.ts +52 -0
  16. package/app/stores/github/probe.ts +97 -0
  17. package/app/stores/github.spec.ts +109 -1
  18. package/app/stores/github.ts +26 -42
  19. package/app/stores/ui/k3sDeepLink.spec.ts +79 -0
  20. package/app/stores/ui/modals.ts +25 -2
  21. package/app/types/github.ts +4 -0
  22. package/app/types/providerConnections.ts +9 -0
  23. package/app/utils/connectionFailures.ts +32 -0
  24. package/app/utils/connectionWarnings.ts +1 -0
  25. package/app/utils/vcs.ts +28 -3
  26. package/i18n/locales/de.json +41 -1
  27. package/i18n/locales/en.json +59 -1
  28. package/i18n/locales/es.json +41 -1
  29. package/i18n/locales/fr.json +41 -1
  30. package/i18n/locales/he.json +41 -1
  31. package/i18n/locales/it.json +41 -1
  32. package/i18n/locales/ja.json +41 -1
  33. package/i18n/locales/pl.json +41 -1
  34. package/i18n/locales/tr.json +41 -1
  35. package/i18n/locales/uk.json +41 -1
  36. package/package.json +2 -2
@@ -12,6 +12,7 @@ import { computed, reactive, ref, watch } from 'vue'
12
12
  import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
13
13
  import type { ConnectionTestResult } from '@cat-factory/contracts'
14
14
  import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
15
+ import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
15
16
  import SecretInput from '~/components/common/SecretInput.vue'
16
17
  import type { ProviderConnection } from '~/types/providerConnections'
17
18
 
@@ -54,6 +55,10 @@ const form = reactive({
54
55
  urlScheme: 'default' as 'default' | 'http' | 'https',
55
56
  })
56
57
  const apiToken = ref('')
58
+ // Flag a bad paste on the field itself rather than leaving it to surface as an opaque probe
59
+ // failure. Destructured at the top level so the template auto-unwraps the refs. Same rule and
60
+ // same copy as the per-type engine form, via the shared composable.
61
+ const { blocking: tokenBlocking, message: tokenProblem } = useServiceAccountTokenProblem(apiToken)
57
62
 
58
63
  const manifestSourceItems = computed(() => [
59
64
  { label: t('settings.providerConnection.kubernetesEnv.sourceColocated'), value: 'colocated' },
@@ -168,6 +173,7 @@ const canSave = computed(
168
173
  !!form.label.trim() &&
169
174
  !!form.apiServerUrl.trim() &&
170
175
  !!apiToken.value.trim() &&
176
+ !tokenBlocking.value &&
171
177
  manifestSourceValid.value &&
172
178
  urlValid.value,
173
179
  )
@@ -191,6 +197,8 @@ const connectBlockedReason = computed(() => {
191
197
  missing.push(t('settings.providerConnection.kubernetesEnv.serviceName'))
192
198
  if (missing.length)
193
199
  return t('settings.providerConnection.form.missingFields', { fields: missing.join(', ') })
200
+ // Repeated from under the token field, so the disabled button is never left unexplained.
201
+ if (tokenBlocking.value) return tokenProblem.value
194
202
  return t('settings.providerConnection.kubernetesEnv.invalidFields')
195
203
  })
196
204
 
@@ -273,6 +281,16 @@ function optional(label: string): string {
273
281
  :help="t('settings.providerConnection.kubernetesEnv.apiTokenHelp')"
274
282
  >
275
283
  <SecretInput v-model="apiToken" class="w-full font-mono" />
284
+ <!-- Rose when the paste is impossible (blocks Test/Save), amber when it is only suspicious
285
+ and the operator may legitimately overrule it. -->
286
+ <p
287
+ v-if="tokenProblem"
288
+ class="mt-1 text-[11px]"
289
+ :class="tokenBlocking ? 'text-rose-400' : 'text-amber-400'"
290
+ data-testid="service-account-token-problem"
291
+ >
292
+ {{ tokenProblem }}
293
+ </p>
276
294
  </UFormField>
277
295
 
278
296
  <!-- Manifest source: where the per-PR resources are read from. -->
@@ -391,7 +409,7 @@ function optional(label: string): string {
391
409
  />
392
410
  </UFormField>
393
411
 
394
- <div v-if="supportsTest" class="flex items-center gap-2">
412
+ <div v-if="supportsTest" class="space-y-1.5">
395
413
  <UButton
396
414
  color="neutral"
397
415
  variant="soft"
@@ -403,12 +421,7 @@ function optional(label: string): string {
403
421
  >
404
422
  {{ t('settings.providerConnection.test.button') }}
405
423
  </UButton>
406
- <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
407
- {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
408
- </span>
409
- <span v-else-if="testResult" class="text-xs text-rose-400">
410
- {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
411
- </span>
424
+ <ConnectionTestVerdict :result="testResult" />
412
425
  </div>
413
426
 
414
427
  <ConnectionWarnings :warnings="testResult?.warnings" />
@@ -15,6 +15,7 @@ import { computed, ref, toRaw, watch } from 'vue'
15
15
  import type { ConnectionTestResult } from '@cat-factory/contracts'
16
16
  import type { ProviderConfigField, ProviderConnectionKind } from '~/types/providerConnections'
17
17
  import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
18
+ import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
18
19
  import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
19
20
  import ProviderManifestEditor from '~/components/settings/ProviderManifestEditor.vue'
20
21
  import KubernetesEnvironmentForm from '~/components/settings/KubernetesEnvironmentForm.vue'
@@ -498,7 +499,7 @@ function fieldHelp(key: string): string | undefined {
498
499
  />
499
500
  </UFormField>
500
501
 
501
- <div v-if="descriptor.supportsTest" class="flex items-center gap-2">
502
+ <div v-if="descriptor.supportsTest" class="space-y-1.5">
502
503
  <UButton
503
504
  color="neutral"
504
505
  variant="soft"
@@ -509,12 +510,7 @@ function fieldHelp(key: string): string | undefined {
509
510
  >
510
511
  {{ t('settings.providerConnection.test.button') }}
511
512
  </UButton>
512
- <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
513
- {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
514
- </span>
515
- <span v-else-if="testResult" class="text-xs text-rose-400">
516
- {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
517
- </span>
513
+ <ConnectionTestVerdict :result="testResult" />
518
514
  </div>
519
515
 
520
516
  <ConnectionWarnings :warnings="testResult?.warnings" />
@@ -20,6 +20,7 @@ import { environmentManifestSchema, runnerPoolManifestSchema } from '@cat-factor
20
20
  import type { ConnectionTestResult } from '@cat-factory/contracts'
21
21
  import type { ProviderConnectionKind } from '~/types/providerConnections'
22
22
  import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
23
+ import ConnectionTestVerdict from '~/components/settings/ConnectionTestVerdict.vue'
23
24
  import SecretInput from '~/components/common/SecretInput.vue'
24
25
 
25
26
  const props = defineProps<{
@@ -267,7 +268,7 @@ function onSave() {
267
268
  </UFormField>
268
269
  </div>
269
270
 
270
- <div v-if="supportsTest" class="flex items-center gap-2">
271
+ <div v-if="supportsTest" class="space-y-1.5">
271
272
  <UButton
272
273
  color="neutral"
273
274
  variant="soft"
@@ -280,12 +281,7 @@ function onSave() {
280
281
  >
281
282
  {{ t('settings.providerConnection.test.button') }}
282
283
  </UButton>
283
- <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
284
- {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
285
- </span>
286
- <span v-else-if="testResult" class="text-xs text-rose-400">
287
- {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
288
- </span>
284
+ <ConnectionTestVerdict :result="testResult" />
289
285
  </div>
290
286
 
291
287
  <ConnectionWarnings :warnings="testResult?.warnings" />
@@ -24,7 +24,7 @@ watch(
24
24
  () => {
25
25
  void library.probe()
26
26
  // The GitHub pickers need the active board's installation state; probe once so they light up.
27
- void github.probe()
27
+ void github.ensureProbed()
28
28
  },
29
29
  { immediate: true },
30
30
  )
@@ -6,6 +6,7 @@ import {
6
6
  createGitHubRepoContract,
7
7
  disconnectGitHubContract,
8
8
  getGitHubConnectionContract,
9
+ getGitHubPatCheckContract,
9
10
  getGitHubInstallUrlContract,
10
11
  listGitHubAvailableReposContract,
11
12
  listGitHubBranchesContract,
@@ -52,6 +53,12 @@ export function githubApi({ send, ws }: ApiContext) {
52
53
  getGitHubConnection: (workspaceId: string) =>
53
54
  send(getGitHubConnectionContract, { pathPrefix: ws(workspaceId) }),
54
55
 
56
+ // What the personal access token this workspace's runs would authenticate with can actually
57
+ // do. Answers `not_applicable` (not a 404/503) on a deployment that uses a GitHub App or no
58
+ // PAT at all, so the caller makes one unconditional call on board load.
59
+ getGitHubPatCheck: (workspaceId: string) =>
60
+ send(getGitHubPatCheckContract, { pathPrefix: ws(workspaceId) }),
61
+
55
62
  listGitHubInstallations: (workspaceId: string) =>
56
63
  send(listGitHubInstallationsContract, { pathPrefix: ws(workspaceId) }),
57
64
 
@@ -0,0 +1,52 @@
1
+ import { computed, type ComputedRef, type Ref } from 'vue'
2
+ import {
3
+ classifyServiceAccountToken,
4
+ isFatalServiceAccountTokenProblem,
5
+ type ServiceAccountTokenProblem,
6
+ } from '@cat-factory/contracts'
7
+
8
+ // Inline validation of a pasted Kubernetes ServiceAccount token, shared by the two kube connect
9
+ // forms so they cannot drift on what a bad paste is or on what to say about it.
10
+ //
11
+ // The rule itself is in `@cat-factory/contracts` because the backend enforces the same one (see
12
+ // `KubernetesApiClient`), and this is the SPA half of the split CLAUDE.md prescribes: the backend
13
+ // emits a machine-readable code, the SPA owns the translated prose. So the map below is the one
14
+ // place a problem code becomes copy.
15
+
16
+ /**
17
+ * The message key per problem, as an exhaustive `Record`: a code added to the contract union fails
18
+ * the typecheck here until it has copy, rather than rendering as a silently missing hint. The keys
19
+ * are literals for the same reason they are elsewhere in the SPA (an assembled key is invisible to
20
+ * the typed-message-key check), and they sit under the shared `providerConnection` namespace
21
+ * because both the per-type engine form and the legacy single-connection form show them.
22
+ */
23
+ const MESSAGE_KEYS: Record<ServiceAccountTokenProblem, string> = {
24
+ whitespace: 'settings.providerConnection.serviceAccountToken.whitespace',
25
+ 'base64-encoded': 'settings.providerConnection.serviceAccountToken.base64Encoded',
26
+ 'not-a-jwt': 'settings.providerConnection.serviceAccountToken.notAJwt',
27
+ }
28
+
29
+ export interface ServiceAccountTokenCheck {
30
+ /** The problem code, or null when the value looks fine (and when it is empty). */
31
+ problem: ComputedRef<ServiceAccountTokenProblem | null>
32
+ /**
33
+ * Whether the problem should BLOCK Test and Save. True only for the impossible case (whitespace
34
+ * inside the token), never for the merely-suspicious shapes: a `--token-auth-file` apiserver
35
+ * accepts an arbitrary static bearer token, and a check that cannot be sure must not be the
36
+ * thing that stops a legitimate cluster being configured.
37
+ */
38
+ blocking: ComputedRef<boolean>
39
+ /** The translated hint to render under the field, or '' when there is nothing to say. */
40
+ message: ComputedRef<string>
41
+ }
42
+
43
+ /** Classify the live value of a token field and render the verdict as translated copy. */
44
+ export function useServiceAccountTokenProblem(token: Ref<string>): ServiceAccountTokenCheck {
45
+ const { t } = useI18n()
46
+ const problem = computed(() => classifyServiceAccountToken(token.value))
47
+ return {
48
+ problem,
49
+ blocking: computed(() => !!problem.value && isFatalServiceAccountTokenProblem(problem.value)),
50
+ message: computed(() => (problem.value ? t(MESSAGE_KEYS[problem.value]) : '')),
51
+ }
52
+ }
@@ -0,0 +1,97 @@
1
+ import type { Ref } from 'vue'
2
+ import type { GitHubPatCheck } from '~/types/domain'
3
+ import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
4
+ import type { GitHubStoreContext } from '~/stores/github/context'
5
+
6
+ /**
7
+ * The board-load probe: everything the store learns about the deployment's VCS setup in one round
8
+ * trip, before anything is clicked.
9
+ *
10
+ * Three questions, deliberately not three failure modes:
11
+ * - Is the integration there at all, and what is bound? (`available` + `connection`)
12
+ * - What could be connected, for the not-connected UI? (`connectOptions`)
13
+ * - Can the token a run would use actually push? (`patCheck`)
14
+ *
15
+ * Extracted from the store setup when the credential check pushed it past the function-size
16
+ * ratchet, and it is the right seam rather than a convenient one: these three reads share a
17
+ * lifecycle (fired together on board open, reset together on workspace switch) and nothing else in
18
+ * the store does.
19
+ *
20
+ * The credential check is the odd one out in TWO ways, and both are why it is single-flighted
21
+ * SEPARATELY rather than being a third branch of `runProbe`:
22
+ *
23
+ * - It is the only read that leaves the deployment. The others answer from local rows in
24
+ * milliseconds; this one waits on GitHub, up to a `GET /user` plus a repository read each. So
25
+ * it is started beside them and never awaited by the caller: a modal that awaits `probe()` to
26
+ * learn whether the integration is available would otherwise sit behind a slow or unreachable
27
+ * GitHub for as long as those calls take, to render a banner it does not own.
28
+ * - It is a DIAGNOSTIC, not data any caller reads, so it follows the DOOR rather than the batch.
29
+ * `ensureProbed()` (the on-board-open fan-out) checks at most once per board; `probe()` (the
30
+ * deliberate-refresh door) re-checks, because the surfaces that force a refresh are the ones
31
+ * that just changed what the answer depends on: linking a repository to a service frame is
32
+ * what turns "this board targets no GitHub repository" into a verdict at all. A panel that
33
+ * merely wants to know whether the integration is available belongs on `ensureProbed()`, and
34
+ * the ones whose own comments said "probe once so the pickers light up" were moved onto it.
35
+ */
36
+ export function createGitHubProbe(
37
+ ctx: GitHubStoreContext,
38
+ patCheck: Ref<GitHubPatCheck | null>,
39
+ ): { probe: () => Promise<void>; ensureProbed: () => Promise<void> } {
40
+ const { api, workspace, available, connection, connectOptions } = ctx
41
+
42
+ async function runPatCheck(): Promise<void> {
43
+ // Which board asked, captured BEFORE the await: a workspace switch mid-flight must not land
44
+ // this board's verdict on the next one's banner. The single-flight wrapper re-keys on the id
45
+ // but cannot un-assign a value the run already wrote.
46
+ const askedFor = workspace.workspaceId
47
+ if (!askedFor) return
48
+ // A failure leaves the previous value alone rather than clearing it: `null` means "not
49
+ // answered", which the banner reads as nothing to say, and overwriting a real verdict with
50
+ // it would silently retract a warning the reader has not acted on.
51
+ const check = await api.getGitHubPatCheck(askedFor).catch(() => null)
52
+ if (check && workspace.workspaceId === askedFor) patCheck.value = check
53
+ }
54
+
55
+ const patProbe = useSingleFlightProbe(runPatCheck, () => workspace.workspaceId)
56
+
57
+ async function runConnectionReads(): Promise<void> {
58
+ if (!workspace.workspaceId) return
59
+ try {
60
+ const [{ connection: conn }, options] = await Promise.all([
61
+ api.getGitHubConnection(workspace.requireId()),
62
+ api
63
+ .listVcsConnectOptions(workspace.requireId())
64
+ .then((r) => r.options)
65
+ .catch(() => []),
66
+ ])
67
+ available.value = true
68
+ connection.value = conn
69
+ connectOptions.value = options
70
+ } catch {
71
+ // 503 (integration disabled) or any error → hide the UI entry points.
72
+ available.value = false
73
+ connection.value = null
74
+ connectOptions.value = []
75
+ }
76
+ }
77
+
78
+ // Single-flight the connection reads (app-startup initiative, item 12): `probe()` still re-reads
79
+ // on demand, but the on-board-open callers (the board page's onboarding gate + the SideBar) use
80
+ // `ensureProbed()` so their duplicate fire collapses to one request per board. A workspace switch
81
+ // (new id) re-probes.
82
+ const connectionProbe = useSingleFlightProbe(runConnectionReads, () => workspace.workspaceId)
83
+
84
+ // The credential check rides the same DOOR the caller opened but never its await, so a slow or
85
+ // unreachable GitHub delays nothing a caller is waiting on. Awaiting only the connection reads
86
+ // is what keeps `await github.probe()` a local-row read, which is what its callers treat it as.
87
+ return {
88
+ probe: () => {
89
+ void patProbe.probe()
90
+ return connectionProbe.probe()
91
+ },
92
+ ensureProbed: () => {
93
+ void patProbe.ensureProbed()
94
+ return connectionProbe.ensureProbed()
95
+ },
96
+ }
97
+ }
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, vi, type Mock } from 'vitest'
2
2
  import { useGitHubStore } from '~/stores/github'
3
3
  import { useWorkspaceStore } from '~/stores/workspace'
4
- import type { GitHubConnection, GitHubRepo, VcsConnectOption } from '~/types/domain'
4
+ import type { GitHubConnection, GitHubPatCheck, GitHubRepo, VcsConnectOption } from '~/types/domain'
5
5
 
6
6
  // The VCS connect surface of the (single, GitHub-shaped) repo store: which connect methods the
7
7
  // deployment offers, the per-workspace GitLab PAT connect, and the provider-routed disconnect.
@@ -29,6 +29,7 @@ function stubApi<T extends Record<string, Mock>>(api: T) {
29
29
  listGitHubRepos: vi.fn().mockResolvedValue([]),
30
30
  listGitHubPullRequests: vi.fn().mockResolvedValue([]),
31
31
  listGitHubIssues: vi.fn().mockResolvedValue([]),
32
+ getGitHubPatCheck: vi.fn().mockResolvedValue({ state: 'not_applicable' }),
32
33
  ...api,
33
34
  }
34
35
  vi.stubGlobal('useApi', () => full)
@@ -285,3 +286,110 @@ describe('github store — repo web links', () => {
285
286
  )
286
287
  })
287
288
  })
289
+
290
+ // The credential check rides the same probe DOOR, but neither the same failure nor the same
291
+ // await. Local mode reaches GitHub with a personal access token and wires no App module, so the
292
+ // connection read 503s exactly where this check matters most; sharing that catch would have
293
+ // discarded the answer there. And it is the only read that leaves the deployment, so a caller
294
+ // awaiting `probe()` must never end up waiting on GitHub.
295
+ describe('github store — GitHub PAT credential check', () => {
296
+ const REPORT: GitHubPatCheck = {
297
+ state: 'checked',
298
+ report: {
299
+ source: 'deployment',
300
+ kind: 'classic',
301
+ capabilities: { push: 'missing', pullRequests: 'missing', workflows: 'missing' },
302
+ probedRepos: [],
303
+ deniedRepos: [],
304
+ unprobedRepoCount: 0,
305
+ webUrl: 'https://github.com',
306
+ },
307
+ }
308
+
309
+ it('resolves the check alongside the connection probe', async () => {
310
+ stubApi({
311
+ getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
312
+ listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
313
+ getGitHubPatCheck: vi.fn().mockResolvedValue(REPORT),
314
+ })
315
+ const github = storeWithWorkspace()
316
+
317
+ await github.probe()
318
+
319
+ await vi.waitFor(() => expect(github.patCheck).toEqual(REPORT))
320
+ })
321
+
322
+ it('keeps the check when the connection read fails, as it does in local mode', async () => {
323
+ stubApi({
324
+ getGitHubConnection: vi.fn().mockRejectedValue(new Error('503')),
325
+ listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
326
+ getGitHubPatCheck: vi.fn().mockResolvedValue(REPORT),
327
+ })
328
+ const github = storeWithWorkspace()
329
+
330
+ await github.probe()
331
+
332
+ expect(github.available).toBe(false)
333
+ await vi.waitFor(() => expect(github.patCheck).toEqual(REPORT))
334
+ })
335
+
336
+ // A failed READ is not a verdict: `null` says "not answered", which the banner renders as
337
+ // nothing. Collapsing it onto a clean report would be an all-clear nobody established.
338
+ it('leaves the check unanswered when its own read fails', async () => {
339
+ stubApi({
340
+ getGitHubConnection: vi.fn().mockResolvedValue({ connection: null }),
341
+ listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
342
+ getGitHubPatCheck: vi.fn().mockRejectedValue(new Error('500')),
343
+ })
344
+ const github = storeWithWorkspace()
345
+
346
+ await github.probe()
347
+
348
+ expect(github.patCheck).toBeNull()
349
+ })
350
+
351
+ // The reason it is not awaited: two modals block their open on `probe()`, and every other
352
+ // read behind it answers from local rows. Awaited, an unreachable GitHub held those modals
353
+ // for the full outbound timeout to settle a banner they do not render.
354
+ it('does not make callers wait on the outbound check', async () => {
355
+ let settleCheck = (_: GitHubPatCheck) => {}
356
+ stubApi({
357
+ getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
358
+ listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
359
+ getGitHubPatCheck: vi.fn().mockReturnValue(
360
+ new Promise<GitHubPatCheck>((resolve) => {
361
+ settleCheck = resolve
362
+ }),
363
+ ),
364
+ })
365
+ const github = storeWithWorkspace()
366
+
367
+ await github.probe()
368
+
369
+ expect(github.available).toBe(true)
370
+ expect(github.patCheck).toBeNull()
371
+ settleCheck(REPORT)
372
+ await vi.waitFor(() => expect(github.patCheck).toEqual(REPORT))
373
+ })
374
+
375
+ // The on-board-open fan-out fires the probe from several places at once. The credential check
376
+ // spends the user's GitHub rate limit, so it collapses to one per board rather than one per
377
+ // caller — while `probe()`, the deliberate-refresh door, still re-checks, because the surfaces
378
+ // that force a refresh are the ones that just changed what the answer depends on.
379
+ it('checks once per board across the on-open fan-out, and again on a deliberate refresh', async () => {
380
+ const getGitHubPatCheck = vi.fn().mockResolvedValue(REPORT)
381
+ stubApi({
382
+ getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
383
+ listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
384
+ getGitHubPatCheck,
385
+ })
386
+ const github = storeWithWorkspace()
387
+
388
+ await Promise.all([github.ensureProbed(), github.ensureProbed()])
389
+ await github.ensureProbed()
390
+ await vi.waitFor(() => expect(getGitHubPatCheck).toHaveBeenCalledTimes(1))
391
+
392
+ await github.probe()
393
+ await vi.waitFor(() => expect(getGitHubPatCheck).toHaveBeenCalledTimes(2))
394
+ })
395
+ })
@@ -6,6 +6,7 @@ import type {
6
6
  GitHubConnection,
7
7
  GitHubInstallationOption,
8
8
  GitHubIssue,
9
+ GitHubPatCheck,
9
10
  GitHubPullRequest,
10
11
  GitHubRepo,
11
12
  RepoTreeEntry,
@@ -13,12 +14,12 @@ import type {
13
14
  VcsProvider,
14
15
  } from '~/types/domain'
15
16
  import { branchWebUrl, issueWebUrl, pullWebUrl, repoWebUrl } from '~/utils/vcs'
16
- import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
17
17
  import { useUpsertList } from '~/composables/useUpsertList'
18
18
  import { useWorkspaceStore } from '~/stores/workspace'
19
19
  import { useServicesStore } from '~/stores/services'
20
20
  import { pullKey, type GitHubStoreContext } from '~/stores/github/context'
21
21
  import { createGitHubConnectionActions } from '~/stores/github/connection'
22
+ import { createGitHubProbe } from '~/stores/github/probe'
22
23
  import { createGitHubRepoActions } from '~/stores/github/repoActions'
23
24
  import { createVcsConnectActions, createVcsProviderViews } from '~/stores/github/vcsConnect'
24
25
 
@@ -41,6 +42,13 @@ export const useGitHubStore = defineStore('github', () => {
41
42
  const connection = ref<GitHubConnection | null>(null)
42
43
  /** The connect surfaces this deployment serves; resolved by the probe alongside `connection`. */
43
44
  const connectOptions = ref<VcsConnectOption[]>([])
45
+ /**
46
+ * What the personal access token this workspace's runs would use can actually do, resolved by
47
+ * the probe. `null` = not answered (unprobed, or the read failed); the check's own
48
+ * `not_applicable` state is what "there is no PAT here" looks like. The two are kept apart
49
+ * because only the second is a fact.
50
+ */
51
+ const patCheck = ref<GitHubPatCheck | null>(null)
44
52
  /** Discovered App installations for the connect picker; loaded on demand. */
45
53
  const installations = ref<GitHubInstallationOption[]>([])
46
54
  const loadingInstallations = ref(false)
@@ -124,37 +132,6 @@ export const useGitHubStore = defineStore('github', () => {
124
132
  return branchWebUrl(providerOfRepo(repoGithubId), repoUrl(repoGithubId), branch)
125
133
  }
126
134
 
127
- /**
128
- * Probe the integration: resolves `available`, the current connection, and which connect
129
- * surfaces the deployment serves. The capability read rides the same round trip (it is what
130
- * the not-connected UI renders from), and degrades to "no connect surface" on its own.
131
- */
132
- async function runProbe() {
133
- if (!workspace.workspaceId) return
134
- try {
135
- const [{ connection: conn }, options] = await Promise.all([
136
- api.getGitHubConnection(workspace.requireId()),
137
- api
138
- .listVcsConnectOptions(workspace.requireId())
139
- .then((r) => r.options)
140
- .catch(() => []),
141
- ])
142
- available.value = true
143
- connection.value = conn
144
- connectOptions.value = options
145
- } catch {
146
- // 503 (integration disabled) or any error → hide the UI entry points.
147
- available.value = false
148
- connection.value = null
149
- connectOptions.value = []
150
- }
151
- }
152
- // Single-flight the probe (app-startup initiative, item 12): `probe()` still re-reads on demand,
153
- // but the on-board-open callers (the board page's onboarding gate + the SideBar) use
154
- // `ensureProbed()` so their duplicate fire collapses to one request per board. A workspace switch
155
- // (new id) re-probes.
156
- const { probe, ensureProbed } = useSingleFlightProbe(runProbe, () => workspace.workspaceId)
157
-
158
135
  /** Load the cached repos, pull requests and issues for the workspace. */
159
136
  async function load() {
160
137
  if (!connected.value) return
@@ -173,16 +150,6 @@ export const useGitHubStore = defineStore('github', () => {
173
150
  }
174
151
  }
175
152
 
176
- /**
177
- * Ensure the projection (repos/PRs/issues) is loaded at least once — for views
178
- * that need it without opening the GitHub panel (e.g. the inspector's repo link).
179
- * Probes the integration first if it hasn't been yet.
180
- */
181
- async function ensureLoaded() {
182
- if (available.value === null) await probe()
183
- if (connected.value && repos.value.length === 0) await load()
184
- }
185
-
186
153
  /** Full file listing per repo (recursive tree), cached by GitHub numeric id. */
187
154
  const repoFiles = ref<Record<number, RepoTreeEntry[]>>({})
188
155
 
@@ -211,6 +178,21 @@ export const useGitHubStore = defineStore('github', () => {
211
178
  connected,
212
179
  load,
213
180
  }
181
+ // The board-load probe (integration availability + the bound connection + the connect options +
182
+ // the credential check). Built from the context rather than inline, so this setup stays within
183
+ // the function-size ratchet the credential check pushed it past.
184
+ const { probe, ensureProbed } = createGitHubProbe(context, patCheck)
185
+
186
+ /**
187
+ * Ensure the projection (repos/PRs/issues) is loaded at least once — for views
188
+ * that need it without opening the GitHub panel (e.g. the inspector's repo link).
189
+ * Probes the integration first if it hasn't been yet.
190
+ */
191
+ async function ensureLoaded() {
192
+ if (available.value === null) await probe()
193
+ if (connected.value && repos.value.length === 0) await load()
194
+ }
195
+
214
196
  const connectionActions = createGitHubConnectionActions(context)
215
197
  const repoActions = createGitHubRepoActions(context)
216
198
  const vcsConnectActions = createVcsConnectActions(context)
@@ -227,6 +209,7 @@ export const useGitHubStore = defineStore('github', () => {
227
209
  available.value = null
228
210
  connection.value = null
229
211
  connectOptions.value = []
212
+ patCheck.value = null
230
213
  installations.value = []
231
214
  repos.value = []
232
215
  availableRepos.value = []
@@ -240,6 +223,7 @@ export const useGitHubStore = defineStore('github', () => {
240
223
  available,
241
224
  connection,
242
225
  connectOptions,
226
+ patCheck,
243
227
  installations,
244
228
  loadingInstallations,
245
229
  repos,
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { createUiModals } from '~/stores/ui/modals'
3
+
4
+ /**
5
+ * The `cat-factory k3s` CLI hand-off (`?infraSetup=local-k3s&…`), driven through the modals slice
6
+ * directly (plain refs/functions, no Pinia) exactly as the overlay-host slice tests do.
7
+ *
8
+ * What is worth pinning here is the ARRIVAL, not the parsing: the CLI's whole promise is that the
9
+ * operator lands on the one form it just filled in, and both halves of that (the tab AND the
10
+ * section anchor within it) are set in this one function. The prefill is asserted alongside
11
+ * because the deep link is the only thing that ever sets it.
12
+ */
13
+ function openWith(search: string): void {
14
+ window.history.replaceState(null, '', `/${search}`)
15
+ }
16
+
17
+ const K3S_LINK =
18
+ '?infraSetup=local-k3s&label=Local+k3s&apiServerUrl=https%3A%2F%2F127.0.0.1%3A6443' +
19
+ '&namespaceTemplate=cf-env-%7B%7BpullNumber%7D%7D&hostTemplate=%7B%7Bbranch%7D%7D.127.0.0.1.nip.io' +
20
+ '&insecureSkipTlsVerify=1'
21
+
22
+ describe('consumeK3sSetupDeepLink', () => {
23
+ beforeEach(() => {
24
+ openWith('')
25
+ })
26
+
27
+ it('opens the Test-environments tab ANCHORED on the Kubernetes section', () => {
28
+ const ui = createUiModals()
29
+ openWith(K3S_LINK)
30
+ ui.consumeK3sSetupDeepLink()
31
+
32
+ expect(ui.infrastructureOpen.value).toBe(true)
33
+ expect(ui.infrastructureTab.value).toBe('environment')
34
+ // The tab opens on the default-provision picker, so without this the operator lands above
35
+ // the form the CLI just described and has to scroll to find it.
36
+ expect(ui.infrastructureScrollTarget.value).toBe('kubernetes')
37
+ expect(ui.k3sSetupPrefill.value).toEqual({
38
+ label: 'Local k3s',
39
+ apiServerUrl: 'https://127.0.0.1:6443',
40
+ namespaceTemplate: 'cf-env-{{pullNumber}}',
41
+ hostTemplate: '{{branch}}.127.0.0.1.nip.io',
42
+ insecureSkipTlsVerify: true,
43
+ })
44
+ })
45
+
46
+ it('strips the params so a reload neither re-opens the window nor re-anchors it', () => {
47
+ const ui = createUiModals()
48
+ openWith(K3S_LINK)
49
+ ui.consumeK3sSetupDeepLink()
50
+ expect(window.location.search).toBe('')
51
+
52
+ const reloaded = createUiModals()
53
+ reloaded.consumeK3sSetupDeepLink()
54
+ expect(reloaded.infrastructureOpen.value).toBe(false)
55
+ expect(reloaded.infrastructureScrollTarget.value).toBeNull()
56
+ })
57
+
58
+ it('drops an UNCONSUMED anchor on close, so the next plain open does not scroll', () => {
59
+ // The panel clears the target once it has scrolled. Closing before it rendered (the window
60
+ // was dismissed, or the infra probe never resolved) must not leave the anchor armed.
61
+ const ui = createUiModals()
62
+ openWith(K3S_LINK)
63
+ ui.consumeK3sSetupDeepLink()
64
+ ui.closeProviderConnection()
65
+
66
+ expect(ui.infrastructureScrollTarget.value).toBeNull()
67
+ expect(ui.k3sSetupPrefill.value).toBeNull()
68
+ })
69
+
70
+ it('is a no-op for an unrelated query string', () => {
71
+ const ui = createUiModals()
72
+ openWith('?settings=default-test-env')
73
+ ui.consumeK3sSetupDeepLink()
74
+
75
+ expect(ui.infrastructureOpen.value).toBe(false)
76
+ expect(ui.infrastructureScrollTarget.value).toBeNull()
77
+ expect(window.location.search).toBe('?settings=default-test-env')
78
+ })
79
+ })