@cat-factory/app 0.182.1 → 0.183.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -192,6 +192,15 @@ wrong place is invisible until a user cannot find it:
192
192
  It also carries a full "how it works / when you need this / when you can skip it"
193
193
  explanation there rather than a one-line hint, since the decision to run it has to
194
194
  be made before opening a five-minute wizard.
195
+ - **"It talks to an external service" is not what puts a surface in `integrations`.**
196
+ Private package registries connect to npmjs.com and GitHub Packages and still belong
197
+ in Infrastructure, because the question they answer is _what may a container install
198
+ from_ — a property of where agents RUN, which is what the Infrastructure window is
199
+ for. `integrations` is for a system the WORKSPACE links in and would still be a
200
+ coherent product without. Ask which question the destination answers, not whether a
201
+ credential leaves the building. A surface moved between sections must also move its
202
+ entry point: leaving a hub row behind as a shortcut splits the answer across two
203
+ places, so the row goes and the window's tab becomes the single route in.
195
204
 
196
205
  ## Develop & test
197
206
 
@@ -30,7 +30,6 @@ const documents = useDocumentsStore()
30
30
  const tasks = useTasksStore()
31
31
  const tracker = useTrackerStore()
32
32
  const releaseHealth = useReleaseHealthStore()
33
- const packageRegistries = usePackageRegistriesStore()
34
33
  const publicApiKeys = usePublicApiKeysStore()
35
34
  const userSecrets = useUserSecretsStore()
36
35
  const uiMode = useUiModeStore()
@@ -60,7 +59,6 @@ watch(
60
59
  if (isOpen) {
61
60
  query.value = ''
62
61
  void releaseHealth.ensureLoaded().catch(() => {})
63
- void packageRegistries.ensureLoaded().catch(() => {})
64
62
  void publicApiKeys.ensureLoaded().catch(() => {})
65
63
  void userSecrets.load().catch(() => {})
66
64
  }
@@ -271,22 +269,10 @@ const groups = computed<IntegrationGroup[]>(() => {
271
269
  })
272
270
  }
273
271
 
274
- // --- Development (private package registries + API access tokens) -----------
275
- // Each row is gated like observability: hidden until a probe confirms its module is
276
- // wired (`available === true`), so an unconfigured backend doesn't show a dead row.
272
+ // --- Development (API access tokens) ---------------------------------------
273
+ // Gated like observability: hidden until a probe confirms its module is wired
274
+ // (`available === true`), so an unconfigured backend doesn't show a dead row.
277
275
  const development: IntegrationItem[] = []
278
- if (packageRegistries.available) {
279
- const hasEntries = packageRegistries.entries.length > 0
280
- development.push({
281
- key: 'package-registries',
282
- icon: 'i-lucide-package',
283
- label: t('layout.integrationsHub.items.packageRegistries.label'),
284
- description: t('layout.integrationsHub.items.packageRegistries.description'),
285
- status: hasEntries ? t('layout.integrationsHub.status.connected') : undefined,
286
- connected: hasEntries,
287
- onClick: () => go(ui.openPackageRegistries),
288
- })
289
- }
290
276
  if (publicApiKeys.available) {
291
277
  const hasKeys = publicApiKeys.keys.length > 0
292
278
  development.push({
@@ -303,8 +289,9 @@ const groups = computed<IntegrationGroup[]>(() => {
303
289
  out.push({ title: t('layout.integrationsHub.groups.development'), items: development })
304
290
 
305
291
  // NOTE: Infrastructure (agent-container execution + Tester environments + the local-mode
306
- // warm pool/checkout) is no longer listed here it moved to its OWN top-level navbar menu
307
- // (SideBar → "Infrastructure" → the tabbed Infrastructure window). See `ui.openInfrastructure`.
292
+ // warm pool/checkout + the private package registries a checkout installs from) is no longer
293
+ // listed here — it moved to its OWN top-level navbar menu (SideBar → "Infrastructure" → the
294
+ // tabbed Infrastructure window). See `ui.openInfrastructure`.
308
295
 
309
296
  // --- Personal (only you) — fallback when there is no UserMenu to host "My setup" -------
310
297
  // Per-user connections normally live in the My-setup hub; with auth disabled they fold in
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ infrastructureTabs,
4
+ openInfrastructureTab,
5
+ repinInfrastructureTab,
6
+ } from './InfrastructureWindow.logic'
7
+
8
+ const NONE = { agents: false, environments: false, packageRegistries: false }
9
+
10
+ describe('infrastructureTabs', () => {
11
+ it('shows nothing when no probe reports a backend', () => {
12
+ expect(infrastructureTabs(NONE)).toEqual([])
13
+ })
14
+
15
+ it('rides the environment probe for shared stacks', () => {
16
+ // A shared stack is infra a Tester environment ATTACHES to, so it cannot stand on its own:
17
+ // without an environment backend there is nothing to attach it to.
18
+ expect(infrastructureTabs({ ...NONE, environments: true })).toEqual([
19
+ 'environment',
20
+ 'shared-stacks',
21
+ ])
22
+ })
23
+
24
+ it('gates the registries tab on its own module probe', () => {
25
+ // The backend 503s the module with no encryption key. That is independent of any execution
26
+ // or test-env backend, so the tab can be the ONLY one a deployment shows.
27
+ expect(infrastructureTabs({ ...NONE, packageRegistries: true })).toEqual(['package-registries'])
28
+ expect(infrastructureTabs({ ...NONE, agents: true })).toEqual(['runner-pool'])
29
+ })
30
+
31
+ it('orders tabs by the question they answer, not by which probe resolved', () => {
32
+ expect(
33
+ infrastructureTabs({ agents: true, environments: true, packageRegistries: true }),
34
+ ).toEqual(['runner-pool', 'environment', 'shared-stacks', 'package-registries'])
35
+ })
36
+ })
37
+
38
+ describe('openInfrastructureTab', () => {
39
+ it('honours an available deep-linked tab over the first one', () => {
40
+ expect(openInfrastructureTab(['runner-pool', 'package-registries'], 'package-registries')).toBe(
41
+ 'package-registries',
42
+ )
43
+ })
44
+
45
+ it('falls back to the first available tab when the request is off', () => {
46
+ expect(openInfrastructureTab(['runner-pool'], 'package-registries')).toBe('runner-pool')
47
+ })
48
+
49
+ it('keeps naming the requested tab when nothing is available yet', () => {
50
+ // Every probe is still in flight on the first open. Holding the request (rather than
51
+ // inventing a selection) is what lets the re-pin land on it the moment its probe resolves.
52
+ expect(openInfrastructureTab([], 'package-registries')).toBe('package-registries')
53
+ })
54
+ })
55
+
56
+ describe('repinInfrastructureTab', () => {
57
+ it('leaves the user where they are when their tab is still available', () => {
58
+ // The list GROWS as probes resolve; someone reading a tab must not be yanked off it.
59
+ expect(
60
+ repinInfrastructureTab(
61
+ ['runner-pool', 'environment', 'package-registries'],
62
+ 'environment',
63
+ 'runner-pool',
64
+ true,
65
+ ),
66
+ ).toBe('environment')
67
+ })
68
+
69
+ it('lands on the deep-linked tab once its probe resolves', () => {
70
+ // The registries probe is the slow one: the window opened on `runner-pool` because
71
+ // `package-registries` was not there yet, and this is the moment it arrives.
72
+ expect(
73
+ repinInfrastructureTab(
74
+ ['runner-pool', 'package-registries'],
75
+ 'shared-stacks',
76
+ 'package-registries',
77
+ true,
78
+ ),
79
+ ).toBe('package-registries')
80
+ })
81
+
82
+ it('does not steal the selection while the probes are still settling', () => {
83
+ // A transient list holding only the fastest probe's tab. Falling back to it here would pin
84
+ // the user to whichever probe won the race and then REJECT the deep link for being a peer
85
+ // of the current tab.
86
+ expect(
87
+ repinInfrastructureTab(['runner-pool'], 'package-registries', 'package-registries', false),
88
+ ).toBe('package-registries')
89
+ })
90
+
91
+ it('falls back to the first tab once loading settled and the request is unavailable', () => {
92
+ expect(
93
+ repinInfrastructureTab(['runner-pool'], 'package-registries', 'package-registries', true),
94
+ ).toBe('runner-pool')
95
+ })
96
+
97
+ it('holds the current tab when settling left nothing available at all', () => {
98
+ expect(repinInfrastructureTab([], 'environment', 'environment', true)).toBe('environment')
99
+ })
100
+ })
@@ -0,0 +1,78 @@
1
+ import type { InfrastructureTab } from '~/types/providerConnections'
2
+
3
+ // Pure tab arithmetic behind `InfrastructureWindow.vue`: which tabs exist for this deployment,
4
+ // and which one to show. Extracted so it is unit-tested directly rather than through the DOM —
5
+ // the window's tabs come from THREE probes that resolve independently (the infrastructure
6
+ // capability, the provider-connection store, the package-registries module), so the interesting
7
+ // behaviour is what happens while the list is still growing, which a rendered-component test
8
+ // reproduces only by accident.
9
+ //
10
+ // Labels stay in the component: a label resolved here would have to travel as a message KEY and
11
+ // be fed to `t()` as a variable, which is exactly what defeats the typed-message-key check. The
12
+ // component maps a tab onto a literal `t()` call through an exhaustive Record instead.
13
+
14
+ /** Which of the window's tabs this deployment can show, as their probes currently read. */
15
+ export interface InfrastructureTabAvailability {
16
+ /** An execution backend is reported (runner pool / container runtime / local). */
17
+ agents: boolean
18
+ /** A test-environment backend is reported. Also gates the shared-stacks tab. */
19
+ environments: boolean
20
+ /** The package-registries module answered its probe affirmatively (it 503s unconfigured). */
21
+ packageRegistries: boolean
22
+ }
23
+
24
+ /**
25
+ * The window's tabs, in display order. Order is the reading order of the questions they answer:
26
+ * where agent containers run, where test environments run, what those environments attach to,
27
+ * and what a checkout may install from.
28
+ *
29
+ * Shared stacks ride the test-environment probe because a stack is infra an environment attaches
30
+ * to — there is nothing to attach without an environment backend.
31
+ */
32
+ export function infrastructureTabs(available: InfrastructureTabAvailability): InfrastructureTab[] {
33
+ const tabs: InfrastructureTab[] = []
34
+ if (available.agents) tabs.push('runner-pool')
35
+ if (available.environments) tabs.push('environment', 'shared-stacks')
36
+ if (available.packageRegistries) tabs.push('package-registries')
37
+ return tabs
38
+ }
39
+
40
+ /**
41
+ * The tab to show when the window OPENS. The deep-linked request wins whenever it is available;
42
+ * otherwise fall back to the first tab that is.
43
+ *
44
+ * Whatever tab was showing last time is deliberately NOT consulted: the caller has just asked
45
+ * for `requested`, and honouring a leftover selection over it would silently ignore the deep
46
+ * link. Falling back to `requested` when nothing is available at all keeps the ref naming the
47
+ * thing that was asked for, so the re-pin below can land on it once its probe resolves.
48
+ */
49
+ export function openInfrastructureTab(
50
+ tabs: readonly InfrastructureTab[],
51
+ requested: InfrastructureTab,
52
+ ): InfrastructureTab {
53
+ if (tabs.includes(requested)) return requested
54
+ return tabs[0] ?? requested
55
+ }
56
+
57
+ /**
58
+ * The tab to show after the tab list CHANGES (a probe resolved). Unlike the open-time choice
59
+ * this one protects the user's current position: a tab list that grows under someone reading a
60
+ * tab must not yank them elsewhere.
61
+ *
62
+ * `settled` is what stops the transient single-tab list from stealing the selection. The probes
63
+ * resolve independently, so the list legitimately passes through states missing tabs that are
64
+ * about to appear; falling back to the first tab before the probes have settled would pin the
65
+ * user to whichever one happened to resolve first, and the deep-linked tab would then be
66
+ * REJECTED for being the current one's peer rather than shown.
67
+ */
68
+ export function repinInfrastructureTab(
69
+ tabs: readonly InfrastructureTab[],
70
+ current: InfrastructureTab,
71
+ requested: InfrastructureTab,
72
+ settled: boolean,
73
+ ): InfrastructureTab {
74
+ if (tabs.includes(current)) return current
75
+ if (tabs.includes(requested)) return requested
76
+ if (settled && tabs.length) return tabs[0]!
77
+ return current
78
+ }
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  // The single tabbed "Infrastructure" window — now a TOP-LEVEL navbar destination (no longer
3
- // reached through the Integrations hub). Two topical tabs:
3
+ // reached through the Integrations hub). Its topical tabs:
4
4
  // - "Agent containers" — where repo-operating agent containers run. Shows the execution
5
5
  // backend selector, the runner-pool connection (ProviderConnectionTab), and — in local
6
6
  // mode — the warm-container-pool + checkout-reuse settings (the local agent-container
@@ -10,24 +10,33 @@
10
10
  // Compose setup (formerly a standalone "Environment setup" sidebar entry — it writes a
11
11
  // service's Compose recipe plus the workspace's Compose handler, so it belongs beside the
12
12
  // settings it edits rather than at the same level as them).
13
+ // - "Shared stacks" — long-lived Compose infra a Tester environment attaches to, so it rides
14
+ // the same probe as the environments tab (nothing to attach to without one).
15
+ // - "Package registries" — the private npm registries a checkout installs from (formerly an
16
+ // Integrations-hub row). What a container can resolve its dependencies from is part of the
17
+ // execution environment, not an optional external system a workspace links in.
13
18
  // Local-specific affordances render inline, gated on `auth.localMode?.enabled`. A tab whose
14
19
  // backend integration is disabled (503) simply doesn't render.
15
20
  import { computed, ref, watch } from 'vue'
16
- import type { ProviderConnectionKind } from '~/types/providerConnections'
21
+ import type { InfrastructureTab } from '~/types/providerConnections'
22
+ import {
23
+ infrastructureTabs,
24
+ openInfrastructureTab,
25
+ repinInfrastructureTab,
26
+ } from '~/components/settings/InfrastructureWindow.logic'
17
27
  import InfrastructureBackendPicker from '~/components/settings/InfrastructureBackendPicker.vue'
18
28
  import InfraHandlersConfigurator from '~/components/settings/InfraHandlersConfigurator.vue'
19
29
  import DefaultProvisionTypeSection from '~/components/settings/DefaultProvisionTypeSection.vue'
20
30
  import LocalContainerPoolSettings from '~/components/settings/LocalContainerPoolSettings.vue'
21
31
  import SharedStacksPanel from '~/components/settings/SharedStacksPanel.vue'
22
32
  import ComposeEnvironmentSetupSection from '~/components/settings/ComposeEnvironmentSetupSection.vue'
23
-
24
- // The shared-stacks tab uses its own slot key beyond the provider-connection kinds.
25
- type InfraTabValue = ProviderConnectionKind | 'shared-stacks'
33
+ import PackageRegistriesPanel from '~/components/settings/PackageRegistriesPanel.vue'
26
34
 
27
35
  const { t } = useI18n()
28
36
  const ui = useUiStore()
29
37
  const store = useProviderConnectionsStore()
30
38
  const auth = useAuthStore()
39
+ const packageRegistries = usePackageRegistriesStore()
31
40
 
32
41
  const open = computed({
33
42
  get: () => ui.infrastructureOpen,
@@ -45,35 +54,41 @@ const isLocal = computed(() => auth.localMode?.enabled === true)
45
54
  const agentsAvailable = computed(() => (auth.infrastructure?.execution.available.length ?? 0) > 0)
46
55
  const envsAvailable = computed(() => (auth.infrastructure?.testEnv.available.length ?? 0) > 0)
47
56
 
48
- const tabs = computed(() => {
49
- const out: { value: InfraTabValue; label: string; icon: string; slot: string }[] = []
50
- if (agentsAvailable.value)
51
- out.push({
52
- value: 'runner-pool',
53
- label: t('settings.providerConnection.tabs.agentContainers'),
54
- icon: 'i-lucide-server-cog',
55
- slot: 'runner-pool',
56
- })
57
- if (envsAvailable.value)
58
- out.push({
59
- value: 'environment',
60
- label: t('settings.providerConnection.tabs.testEnvironments'),
61
- icon: 'i-lucide-cloud',
62
- slot: 'environment',
63
- })
64
- // Shared stacks are long-lived compose infra a Tester environment attaches to, so they live
65
- // alongside the test-environment config (shown wherever an environment backend is available).
66
- if (envsAvailable.value)
67
- out.push({
68
- value: 'shared-stacks',
69
- label: t('settings.sharedStacks.tab'),
70
- icon: 'i-lucide-layers',
71
- slot: 'shared-stacks',
72
- })
73
- return out
74
- })
57
+ // Tab presentation, keyed by the closed `InfrastructureTab` union so a new tab fails to compile
58
+ // until it is given a label and an icon. The labels are literal `t()` calls for the same reason
59
+ // they are not resolved in the logic module: a key assembled at runtime is invisible to the
60
+ // typed-message-key check.
61
+ const TAB_LABELS = computed<Record<InfrastructureTab, string>>(() => ({
62
+ 'runner-pool': t('settings.providerConnection.tabs.agentContainers'),
63
+ environment: t('settings.providerConnection.tabs.testEnvironments'),
64
+ 'shared-stacks': t('settings.sharedStacks.tab'),
65
+ 'package-registries': t('settings.packageRegistries.tab'),
66
+ }))
67
+ const TAB_ICONS: Record<InfrastructureTab, string> = {
68
+ 'runner-pool': 'i-lucide-server-cog',
69
+ environment: 'i-lucide-cloud',
70
+ 'shared-stacks': 'i-lucide-layers',
71
+ 'package-registries': 'i-lucide-package',
72
+ }
75
73
 
76
- const activeTab = ref<InfraTabValue>(ui.infrastructureTab)
74
+ // `slot` mirrors `value` — the template names one `<template #…>` per tab value.
75
+ const tabs = computed(() =>
76
+ infrastructureTabs({
77
+ agents: agentsAvailable.value,
78
+ environments: envsAvailable.value,
79
+ // The module's own probe (the backend 503s with no encryption key), same gate as the
80
+ // Integrations-hub row this replaced — an unconfigured backend shows no dead tab.
81
+ packageRegistries: packageRegistries.available === true,
82
+ }).map((value) => ({
83
+ value,
84
+ label: TAB_LABELS.value[value],
85
+ icon: TAB_ICONS[value],
86
+ slot: value,
87
+ })),
88
+ )
89
+ const tabValues = computed(() => tabs.value.map((x) => x.value))
90
+
91
+ const activeTab = ref<InfrastructureTab>(ui.infrastructureTab)
77
92
 
78
93
  // Honour the deep-linked tab each time the window opens, falling back to the first available
79
94
  // tab if the requested one is off.
@@ -82,24 +97,25 @@ watch(
82
97
  (isOpen) => {
83
98
  if (!isOpen) return
84
99
  void store.ensureLoaded().catch(() => {})
85
- const requested = ui.infrastructureTab
86
- const available = tabs.value.map((x) => x.value)
87
- activeTab.value = available.includes(requested) ? requested : (available[0] ?? requested)
100
+ // The registries tab gates on this probe, so it has to resolve for the tab to appear at
101
+ // all the panel's own load is a no-op once this settled (`ensureLoaded` coalesces).
102
+ // Swallowed here on purpose: the PANEL reports a load failure, and it can only do that
103
+ // once the tab it lives in exists, so a probe failure has to leave the window itself alone.
104
+ void packageRegistries.ensureLoaded().catch(() => {})
105
+ activeTab.value = openInfrastructureTab(tabValues.value, ui.infrastructureTab)
88
106
  },
89
107
  { immediate: true },
90
108
  )
91
109
  // When availability resolves after open, re-pin onto a valid tab — but keep honouring the
92
- // deep-linked request. The two availability probes resolve independently, so `tabs` can pass
93
- // through a transient single-tab list; only fall back to the first tab once loading settled.
110
+ // deep-linked request. The three availability probes resolve independently, so `tabs` can pass
111
+ // through a transient short list; only fall back to the first tab once loading settled.
94
112
  watch([tabs, () => store.loaded], () => {
95
- const list = tabs.value
96
- if (list.some((x) => x.value === activeTab.value)) return
97
- const requested = ui.infrastructureTab
98
- if (list.some((x) => x.value === requested)) {
99
- activeTab.value = requested
100
- } else if (store.loaded && list.length) {
101
- activeTab.value = list[0]!.value
102
- }
113
+ activeTab.value = repinInfrastructureTab(
114
+ tabValues.value,
115
+ activeTab.value,
116
+ ui.infrastructureTab,
117
+ store.loaded,
118
+ )
103
119
  })
104
120
  </script>
105
121
 
@@ -157,6 +173,9 @@ watch([tabs, () => store.loaded], () => {
157
173
  <template #shared-stacks>
158
174
  <SharedStacksPanel />
159
175
  </template>
176
+ <template #package-registries>
177
+ <PackageRegistriesPanel />
178
+ </template>
160
179
  </UTabs>
161
180
 
162
181
  <p v-else class="px-1 py-6 text-center text-sm text-slate-500">