@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.
@@ -3,24 +3,18 @@
3
3
  // orgs, GitHub Packages) that agent containers use to resolve private dependencies
4
4
  // on checkout. Tokens are write-only: the list renders from the redacted summary
5
5
  // (vendor + scopes + token tail) and an entry is edited by deleting + re-adding.
6
- // Opened from the Integrations hub.
7
- import { computed, reactive, ref, watch } from 'vue'
6
+ // Renders inline inside the Infrastructure window's "Package registries" tab: what a
7
+ // checkout installs from is part of where agent containers RUN, not an optional
8
+ // external system the workspace links in.
9
+ import { computed, onMounted, reactive, ref } from 'vue'
8
10
  import type { PackageRegistryVendor } from '~/types/packageRegistries'
9
- import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
10
11
  import SecretInput from '~/components/common/SecretInput.vue'
11
12
 
12
13
  const { t } = useI18n()
13
- const ui = useUiStore()
14
14
  const store = usePackageRegistriesStore()
15
15
  const toast = useToast()
16
16
  const { confirmAction, toastDone } = useConfirmAction()
17
17
 
18
- const open = computed({
19
- get: () => ui.packageRegistriesOpen,
20
- set: (v: boolean) => (v ? ui.openPackageRegistries() : ui.closePackageRegistries()),
21
- })
22
- const back = useIntegrationBack(open)
23
-
24
18
  // The registry vendors a workspace can connect. Fixed set — the host derives from the
25
19
  // vendor server-side, so it renders read-only here. Vendor names stay verbatim.
26
20
  const VENDORS: { value: PackageRegistryVendor; label: string; host: string }[] = [
@@ -59,18 +53,19 @@ function notifyError(title: string, e: unknown) {
59
53
  })
60
54
  }
61
55
 
62
- watch(
63
- open,
64
- async (isOpen) => {
65
- if (!isOpen) return
66
- try {
67
- await store.ensureLoaded()
68
- } catch (e) {
69
- notifyError(t('settings.packageRegistries.toast.loadFailed'), e)
70
- }
71
- },
72
- { immediate: true },
73
- )
56
+ // The tab this renders in only exists once the window's probe resolved `available === true`, so
57
+ // `ensureLoaded()` here would early-return every time and this error branch would be dead code.
58
+ // Read the list outright instead: the window owns the PROBE (a failure there means no tab), the
59
+ // panel owns the DATA (a failure here means the reader is looking at a list we could not fetch,
60
+ // and must be told). It also drops the staleness `ensureLoaded` carried — reopening the tab
61
+ // after an entry was added elsewhere used to re-render the first load's snapshot.
62
+ onMounted(async () => {
63
+ try {
64
+ await store.load()
65
+ } catch (e) {
66
+ notifyError(t('settings.packageRegistries.toast.loadFailed'), e)
67
+ }
68
+ })
74
69
 
75
70
  async function addEntry() {
76
71
  busy.value = true
@@ -111,108 +106,121 @@ async function removeEntry(entryId: string) {
111
106
  </script>
112
107
 
113
108
  <template>
114
- <UModal
115
- v-model:open="open"
116
- :title="t('settings.packageRegistries.title')"
117
- :ui="{ content: 'max-w-lg' }"
118
- >
119
- <template #title>
120
- <IntegrationBackTitle :title="t('settings.packageRegistries.title')" @back="back" />
121
- </template>
122
- <template #body>
123
- <div class="space-y-4" data-testid="package-registries-panel">
124
- <p class="text-sm text-slate-400">
125
- {{ t('settings.packageRegistries.intro') }}
126
- </p>
127
-
128
- <section
129
- v-if="store.entries.length"
130
- class="space-y-2 rounded-lg border border-slate-700 p-3"
131
- >
132
- <h3 class="text-sm font-semibold">
133
- {{ t('settings.packageRegistries.list.heading') }}
134
- </h3>
135
- <div
136
- v-for="entry in store.entries"
137
- :key="entry.id"
138
- class="flex items-center justify-between gap-2 rounded-md border border-slate-800 px-3 py-2"
139
- >
140
- <div class="min-w-0 space-y-1">
141
- <div class="flex items-center gap-2">
142
- <span class="text-sm font-medium">{{ vendorLabel(entry.vendor) }}</span>
143
- <span class="text-[11px] text-slate-500">
144
- {{ t('settings.packageRegistries.list.tokenTail', { tail: entry.tokenTail }) }}
145
- </span>
146
- </div>
147
- <div class="flex flex-wrap gap-1">
148
- <UBadge
149
- v-for="scope in entry.scopes"
150
- :key="scope"
151
- color="neutral"
152
- variant="soft"
153
- size="sm"
154
- >
155
- {{ scope }}
156
- </UBadge>
157
- </div>
158
- </div>
159
- <UButton
160
- color="error"
161
- variant="ghost"
162
- icon="i-lucide-trash-2"
109
+ <div class="space-y-4" data-testid="package-registries-panel">
110
+ <p class="text-sm text-slate-400">
111
+ {{ t('settings.packageRegistries.intro') }}
112
+ </p>
113
+
114
+ <section v-if="store.entries.length" class="space-y-2 rounded-lg border border-slate-700 p-3">
115
+ <h3 class="text-sm font-semibold">
116
+ {{ t('settings.packageRegistries.list.heading') }}
117
+ </h3>
118
+ <div
119
+ v-for="entry in store.entries"
120
+ :key="entry.id"
121
+ class="flex items-center justify-between gap-2 rounded-md border border-slate-800 px-3 py-2"
122
+ >
123
+ <div class="min-w-0 space-y-1">
124
+ <div class="flex items-center gap-2">
125
+ <span class="text-sm font-medium">{{ vendorLabel(entry.vendor) }}</span>
126
+ <span class="text-[11px] text-slate-500">
127
+ {{ t('settings.packageRegistries.list.tokenTail', { tail: entry.tokenTail }) }}
128
+ </span>
129
+ </div>
130
+ <div class="flex flex-wrap gap-1">
131
+ <UBadge
132
+ v-for="scope in entry.scopes"
133
+ :key="scope"
134
+ color="neutral"
135
+ variant="soft"
163
136
  size="sm"
164
- :loading="busy"
165
- :data-testid="`package-registry-delete-${entry.id}`"
166
- :aria-label="t('settings.packageRegistries.list.remove')"
167
- @click="removeEntry(entry.id)"
168
- />
137
+ >
138
+ {{ scope }}
139
+ </UBadge>
140
+ <!-- An entry with no scopes authenticates its host without routing any scope to
141
+ it (the deliberate mixed public/private setup) — say so, or the row reads as
142
+ half-configured. -->
143
+ <span v-if="!entry.scopes.length" class="text-[11px] text-slate-500">
144
+ {{ t('settings.packageRegistries.list.noScopes') }}
145
+ </span>
169
146
  </div>
170
- </section>
171
-
172
- <section class="space-y-3 rounded-lg border border-slate-700 p-3">
173
- <h3 class="text-sm font-semibold">
174
- {{ t('settings.packageRegistries.add.heading') }}
175
- </h3>
176
-
177
- <UFormField :label="t('settings.packageRegistries.add.vendor')">
178
- <USelect
179
- v-model="form.vendor"
180
- :items="VENDORS"
181
- value-key="value"
182
- class="w-full"
183
- data-testid="package-registry-vendor"
184
- />
185
- </UFormField>
186
- <p class="text-[11px] text-slate-500">
187
- {{ t('settings.packageRegistries.add.host', { host: vendorHost }) }}
188
- </p>
189
-
190
- <UFormField
191
- :label="t('settings.packageRegistries.add.scopes')"
192
- :help="t('settings.packageRegistries.add.scopesHelp')"
193
- >
194
- <UInput
195
- v-model="form.scopes"
196
- placeholder="@my-org, @my-other-org"
197
- class="w-full"
198
- data-testid="package-registry-scopes"
199
- />
200
- </UFormField>
201
-
202
- <UFormField :label="t('settings.packageRegistries.add.token')">
203
- <SecretInput v-model="form.token" class="w-full" data-testid="package-registry-token" />
204
- </UFormField>
205
-
206
- <UButton
207
- :loading="busy"
208
- :disabled="!parsedScopes.length || !form.token.trim()"
209
- data-testid="package-registry-save"
210
- @click="addEntry"
211
- >
212
- {{ t('settings.packageRegistries.add.save') }}
213
- </UButton>
214
- </section>
147
+ </div>
148
+ <UButton
149
+ color="error"
150
+ variant="ghost"
151
+ icon="i-lucide-trash-2"
152
+ size="sm"
153
+ :loading="busy"
154
+ :data-testid="`package-registry-delete-${entry.id}`"
155
+ :aria-label="t('settings.packageRegistries.list.remove')"
156
+ @click="removeEntry(entry.id)"
157
+ />
158
+ </div>
159
+ </section>
160
+
161
+ <section class="space-y-3 rounded-lg border border-slate-700 p-3">
162
+ <h3 class="text-sm font-semibold">
163
+ {{ t('settings.packageRegistries.add.heading') }}
164
+ </h3>
165
+
166
+ <UFormField :label="t('settings.packageRegistries.add.vendor')">
167
+ <USelect
168
+ v-model="form.vendor"
169
+ :items="VENDORS"
170
+ value-key="value"
171
+ class="w-full"
172
+ data-testid="package-registry-vendor"
173
+ />
174
+ </UFormField>
175
+ <p class="text-[11px] text-slate-500">
176
+ {{ t('settings.packageRegistries.add.host', { host: vendorHost }) }}
177
+ </p>
178
+
179
+ <UFormField
180
+ :label="t('settings.packageRegistries.add.scopes')"
181
+ :help="t('settings.packageRegistries.add.scopesHelp')"
182
+ >
183
+ <UInput
184
+ v-model="form.scopes"
185
+ placeholder="@my-org, @my-other-org"
186
+ class="w-full"
187
+ data-testid="package-registry-scopes"
188
+ />
189
+ </UFormField>
190
+ <!-- What will actually be SAVED. The parse splits on commas/whitespace and prefixes a
191
+ missing `@`, so the field's text and the stored scopes routinely differ — and now
192
+ that an empty list is a legitimate save, "I typed something that parsed to nothing"
193
+ and "I meant to leave this empty" would otherwise look identical at the button. -->
194
+ <div v-if="parsedScopes.length" class="flex flex-wrap gap-1">
195
+ <UBadge
196
+ v-for="scope in parsedScopes"
197
+ :key="scope"
198
+ color="neutral"
199
+ variant="soft"
200
+ size="sm"
201
+ data-testid="package-registry-parsed-scope"
202
+ >
203
+ {{ scope }}
204
+ </UBadge>
215
205
  </div>
216
- </template>
217
- </UModal>
206
+ <!-- Why leaving this empty is often the RIGHT answer — a scope mapping is all-or-nothing,
207
+ so an org publishing some of its `@org` packages publicly breaks under one. -->
208
+ <p class="text-[11px] text-slate-500">
209
+ {{ t('settings.packageRegistries.add.scopesNote') }}
210
+ </p>
211
+
212
+ <UFormField :label="t('settings.packageRegistries.add.token')">
213
+ <SecretInput v-model="form.token" class="w-full" data-testid="package-registry-token" />
214
+ </UFormField>
215
+
216
+ <UButton
217
+ :loading="busy"
218
+ :disabled="!form.token.trim()"
219
+ data-testid="package-registry-save"
220
+ @click="addEntry"
221
+ >
222
+ {{ t('settings.packageRegistries.add.save') }}
223
+ </UButton>
224
+ </section>
225
+ </div>
218
226
  </template>
@@ -437,7 +437,6 @@ watch(
437
437
  <WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
438
438
  <AccountSettingsPanel v-if="ui.accountSettingsOpen" />
439
439
  <ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
440
- <PackageRegistriesPanel v-if="ui.packageRegistriesOpen" />
441
440
  <ApiTokensPanel v-if="ui.apiTokensOpen" />
442
441
  <InfrastructureWindow v-if="ui.infrastructureOpen" />
443
442
  <EnvironmentSetupWizard v-if="ui.environmentWizardOpen" />
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { usePackageRegistriesStore } from '~/stores/packageRegistries'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { PackageRegistryEntryView } from '~/types/packageRegistries'
5
+
6
+ /**
7
+ * The availability probe and the list read share one `load()`, but their failure contracts are
8
+ * opposite, and the split is what these cases pin:
9
+ *
10
+ * - a 503 is an ANSWER ("this deployment has no registries module") and resolves normally, so
11
+ * the Infrastructure window simply shows no tab;
12
+ * - anything else is a FAILURE that propagates, because the panel — which only renders once
13
+ * the probe already succeeded — is the surface that can tell a reader the list they are
14
+ * looking at could not be fetched.
15
+ */
16
+ function entry(over: Partial<PackageRegistryEntryView> = {}): PackageRegistryEntryView {
17
+ return {
18
+ id: 'pkgreg_1',
19
+ ecosystem: 'npm',
20
+ vendor: 'npmjs',
21
+ scopes: ['@acme'],
22
+ tokenTail: 'cdef',
23
+ ...over,
24
+ }
25
+ }
26
+
27
+ describe('packageRegistries store', () => {
28
+ beforeEach(() => {
29
+ useWorkspaceStore().workspaceId = 'ws1'
30
+ })
31
+
32
+ it('load stores the entries and marks the module available', async () => {
33
+ vi.stubGlobal('useApi', () => ({
34
+ listPackageRegistries: () => Promise.resolve({ entries: [entry()] }),
35
+ }))
36
+
37
+ const store = usePackageRegistriesStore()
38
+ await store.load()
39
+
40
+ expect(store.available).toBe(true)
41
+ expect(store.entries).toHaveLength(1)
42
+ expect(store.loading).toBe(false)
43
+ })
44
+
45
+ it('a definitive 503 latches the module unavailable without throwing', async () => {
46
+ vi.stubGlobal('useApi', () => ({
47
+ listPackageRegistries: () => Promise.reject({ statusCode: 503 }),
48
+ }))
49
+
50
+ const store = usePackageRegistriesStore()
51
+ await expect(store.load()).resolves.toBeUndefined()
52
+
53
+ expect(store.available).toBe(false)
54
+ expect(store.entries).toEqual([])
55
+ })
56
+
57
+ it('a transient failure propagates and leaves `available` null so the probe stays retryable', async () => {
58
+ vi.stubGlobal('useApi', () => ({
59
+ listPackageRegistries: () => Promise.reject({ statusCode: 500 }),
60
+ }))
61
+
62
+ const store = usePackageRegistriesStore()
63
+ await expect(store.load()).rejects.toMatchObject({ statusCode: 500 })
64
+
65
+ // Never cached as a false "unavailable": a reachable-but-flaky backend must not hide a tab
66
+ // the deployment really has.
67
+ expect(store.available).toBeNull()
68
+ expect(store.loading).toBe(false)
69
+ })
70
+
71
+ it('keeps an already-loaded list when a later refresh fails', async () => {
72
+ let fail = false
73
+ vi.stubGlobal('useApi', () => ({
74
+ listPackageRegistries: () =>
75
+ fail ? Promise.reject({ statusCode: 500 }) : Promise.resolve({ entries: [entry()] }),
76
+ }))
77
+
78
+ const store = usePackageRegistriesStore()
79
+ await store.load()
80
+ fail = true
81
+ await expect(store.load()).rejects.toMatchObject({ statusCode: 500 })
82
+
83
+ // The panel reports the failure; it must not also blank the list the reader had.
84
+ expect(store.available).toBe(true)
85
+ expect(store.entries).toHaveLength(1)
86
+ })
87
+
88
+ it('ensureLoaded probes once and stays retryable after a transient failure', async () => {
89
+ let calls = 0
90
+ let fail = true
91
+ vi.stubGlobal('useApi', () => ({
92
+ listPackageRegistries: () => {
93
+ calls += 1
94
+ return fail ? Promise.reject({ statusCode: 500 }) : Promise.resolve({ entries: [] })
95
+ },
96
+ }))
97
+
98
+ const store = usePackageRegistriesStore()
99
+ await expect(store.ensureLoaded()).rejects.toMatchObject({ statusCode: 500 })
100
+ expect(calls).toBe(1)
101
+
102
+ fail = false
103
+ await store.ensureLoaded()
104
+ expect(calls).toBe(2)
105
+ expect(store.available).toBe(true)
106
+
107
+ // Settled now, so a third caller costs no request.
108
+ await store.ensureLoaded()
109
+ expect(calls).toBe(2)
110
+ })
111
+ })
@@ -7,8 +7,9 @@ import { apiErrorStatus } from '~/composables/api/errors'
7
7
  /**
8
8
  * The workspace's private package-registry entries (npm private orgs, GitHub
9
9
  * Packages) that agent containers install with. Tokens are write-only — the store
10
- * only ever holds the redacted summary views. Loaded on demand (the registries
11
- * panel + the Integrations hub badge), not from the snapshot.
10
+ * only ever holds the redacted summary views. Loaded on demand (the Infrastructure
11
+ * window's "Package registries" tab, whose very existence gates on the probe below),
12
+ * not from the snapshot.
12
13
  */
13
14
  export const usePackageRegistriesStore = defineStore('packageRegistries', () => {
14
15
  const api = useApi()
@@ -16,8 +17,8 @@ export const usePackageRegistriesStore = defineStore('packageRegistries', () =>
16
17
  const entries = ref<PackageRegistryEntryView[]>([])
17
18
  const loading = ref(false)
18
19
  // Mirrors the backend's opt-in gate (the module 503s when the encryption key is
19
- // absent): `null` until first probed, then `true`/`false`. The hub hides its
20
- // registries entry point when this is false.
20
+ // absent): `null` until first probed, then `true`/`false`. The Infrastructure window
21
+ // shows no registries tab unless this is `true`.
21
22
  const available = ref<boolean | null>(null)
22
23
  let inFlight: Promise<void> | null = null
23
24
 
@@ -31,13 +32,25 @@ export const usePackageRegistriesStore = defineStore('packageRegistries', () =>
31
32
  } catch (err) {
32
33
  if (apiErrorStatus(err) === 503) {
33
34
  // A definitive 503 means the integration is unconfigured (no encryption key on
34
- // the backend): hide the UI entry points and stop probing.
35
+ // the backend): hide the UI entry points and stop probing. This is an ANSWER, not a
36
+ // failure, so it resolves normally.
35
37
  available.value = false
36
38
  entries.value = []
39
+ return
37
40
  }
38
- // Any other failure (transient 5xx / network) is left untouched: it must not hide
39
- // an already-available panel nor cache a false "unavailable". `available` stays
40
- // `null` when never probed, so `ensureLoaded` remains retryable on the next open.
41
+ // Any other failure (transient 5xx / network) leaves the state untouched: it must not
42
+ // hide an already-available panel nor cache a false "unavailable". `available` stays
43
+ // `null` when never probed, so `ensureLoaded` remains retryable on the next open — and
44
+ // the error PROPAGATES so a caller can say so. Swallowing it made every caller's error
45
+ // branch dead code: "the backend is unreachable" and "your deployment has no registries
46
+ // module" are different problems that must not render identically, and the panel is the
47
+ // one surface that can tell a reader which it hit. The PROBE callers still swallow (a
48
+ // failed probe means no tab, not a broken window) — the split is deliberate.
49
+ //
50
+ // NB `publicApiKeys` carries the same availability shape and still swallows here. It is
51
+ // not being changed alongside: its probe only hides one row of a hub full of others,
52
+ // whereas this one gates the feature's ONLY surface.
53
+ throw err
41
54
  } finally {
42
55
  loading.value = false
43
56
  }
@@ -1,5 +1,6 @@
1
1
  import { ref } from 'vue'
2
2
  import type { DocumentSourceKind, InfraSetupArea, TaskSourceKind } from '~/types/domain'
3
+ import type { InfrastructureTab, ProviderConnectionKind } from '~/types/providerConnections'
3
4
  import type { PendingContext } from '~/composables/useContextLinking'
4
5
  import {
5
6
  DEFAULT_PROVISION_DEEP_LINK_PARAM,
@@ -452,9 +453,8 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
452
453
  // model / agent kind, spend + activity per workspace / service / task type). Admin-gated.
453
454
  // Distinct from `operatorDashboardOpen`, which answers the deployment-HEALTH question.
454
455
  const reportsOpen = ref(false)
455
- // Private package registries: the workspace's npm/GitHub-Packages entries agent
456
- // containers install with. Opened from the Integrations hub.
457
- const packageRegistriesOpen = ref(false)
456
+ // NOTE: private package registries are no longer a panel of their own — they are a tab of
457
+ // the Infrastructure window (`infrastructureOpen`), reached from the navbar.
458
458
  // API access tokens: the workspace's inbound public-API keys external systems present to
459
459
  // the `/api/v1` surface. Opened from the Integrations hub.
460
460
  const apiTokensOpen = ref(false)
@@ -505,13 +505,6 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
505
505
  function closeReports() {
506
506
  reportsOpen.value = false
507
507
  }
508
- function openPackageRegistries() {
509
- resetHubReturn()
510
- packageRegistriesOpen.value = true
511
- }
512
- function closePackageRegistries() {
513
- packageRegistriesOpen.value = false
514
- }
515
508
  function openApiTokens() {
516
509
  resetHubReturn()
517
510
  apiTokensOpen.value = true
@@ -564,7 +557,6 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
564
557
  observabilityConnectionOpen,
565
558
  operatorDashboardOpen,
566
559
  reportsOpen,
567
- packageRegistriesOpen,
568
560
  apiTokensOpen,
569
561
  modelConfigOpen,
570
562
  vendorCredentialsOpen,
@@ -582,8 +574,6 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
582
574
  openReports,
583
575
  closeReports,
584
576
  closeOperatorDashboard,
585
- openPackageRegistries,
586
- closePackageRegistries,
587
577
  openApiTokens,
588
578
  closeApiTokens,
589
579
  openModelConfig,
@@ -683,13 +673,19 @@ function createSettingsModals(resetHubReturn: ResetHubReturn) {
683
673
  */
684
674
  function createInfraModals(resetHubReturn: ResetHubReturn) {
685
675
  // The single tabbed Infrastructure window — a TOP-LEVEL navbar destination (no longer
686
- // reached via the Integrations hub). Two topical tabs: "Agent containers" (the execution
687
- // backend + self-hosted runner pool, plus the local-mode warm pool/checkout) and "Test
688
- // environments" (the ephemeral-environment provider). `infrastructureOpen` is the modal
689
- // flag; `infrastructureTab` selects the tab. `openInfrastructure()` is the navbar entry;
690
- // `openProviderConnection(kind)` remains for deep-links (a banner's "Configure…" button).
676
+ // reached via the Integrations hub). Its topical tabs: "Agent containers" (the execution
677
+ // backend + self-hosted runner pool, plus the local-mode warm pool/checkout), "Test
678
+ // environments" (the ephemeral-environment provider), "Shared stacks" (long-lived Compose
679
+ // infra an environment attaches to) and "Package registries" (the private registries a
680
+ // checkout installs from). `infrastructureOpen` is the modal flag; `infrastructureTab`
681
+ // selects the tab. `openInfrastructure()` is the navbar entry; `openProviderConnection(kind)`
682
+ // remains for deep-links (a banner's "Configure…" button).
683
+ //
684
+ // The ref is typed against the FULL `InfrastructureTab` union, not the provider-connection
685
+ // kinds: a tab this cannot name is a tab nothing can deep-link to, which is how the
686
+ // non-connection tabs ended up reachable only by opening the window and clicking across.
691
687
  const infrastructureOpen = ref(false)
692
- const infrastructureTab = ref<'environment' | 'runner-pool'>('runner-pool')
688
+ const infrastructureTab = ref<InfrastructureTab>('runner-pool')
693
689
  // Non-secret prefill captured from the `cat-factory k3s` CLI deep-link (see
694
690
  // `consumeK3sSetupDeepLink`). When set, the Test-environments tab's kube engine form seeds the
695
691
  // `local-k3s` connection from it; the ServiceAccount token is deliberately NOT in the link (a
@@ -704,12 +700,15 @@ function createInfraModals(resetHubReturn: ResetHubReturn) {
704
700
 
705
701
  // Top-level navbar entry into the Infrastructure window. No hub-return marker (it isn't
706
702
  // reached from the Integrations hub), so the window shows no "Back to Integrations" control.
707
- function openInfrastructure(tab: 'environment' | 'runner-pool' = 'runner-pool') {
703
+ function openInfrastructure(tab: InfrastructureTab = 'runner-pool') {
708
704
  resetHubReturn()
709
705
  infrastructureTab.value = tab
710
706
  infrastructureOpen.value = true
711
707
  }
712
- function openProviderConnection(kind: 'environment' | 'runner-pool') {
708
+ // Deep-link into a PROVIDER's tab specifically (a config banner's "Configure…" button), so
709
+ // this one stays narrowed to the connection kinds — it means "connect this provider", not
710
+ // "open the window somewhere". Use `openInfrastructure(tab)` for any other tab.
711
+ function openProviderConnection(kind: ProviderConnectionKind) {
713
712
  resetHubReturn()
714
713
  infrastructureTab.value = kind
715
714
  infrastructureOpen.value = true
@@ -17,6 +17,18 @@ export type {
17
17
  /** The two infrastructure providers configured through the generic connect form. */
18
18
  export type ProviderConnectionKind = 'environment' | 'runner-pool'
19
19
 
20
+ /**
21
+ * A tab of the Infrastructure window, and the vocabulary `ui.infrastructureTab` selects from.
22
+ *
23
+ * Deliberately WIDER than {@link ProviderConnectionKind}: the window also hosts tabs that are
24
+ * not a provider connection at all (long-lived shared Compose stacks, the workspace's private
25
+ * package registries). Typing the store's tab ref as the connection kinds alone is what made
26
+ * those tabs unreachable by deep link — a banner, a command-palette entry or an Integrations-hub
27
+ * pointer could open the window but never land the user on the tab it meant. Every tab
28
+ * `InfrastructureWindow.vue` can render must have a name here.
29
+ */
30
+ export type InfrastructureTab = ProviderConnectionKind | 'shared-stacks' | 'package-registries'
31
+
20
32
  /** A workspace's provider binding, as exposed to clients (never secret values). */
21
33
  export interface ProviderConnection {
22
34
  /** The runner-backend kind for a runner-pool connection (`manifest` | `kubernetes`). */
@@ -537,20 +537,22 @@
537
537
  "incidentNoun": "der Incident-Provider"
538
538
  },
539
539
  "packageRegistries": {
540
- "title": "Private Package-Registries",
540
+ "tab": "Package-Registries",
541
541
  "intro": "Verbinde die privaten Registries, aus denen deine Repositories installieren. Agenten erhalten sie beim Auschecken eines Repositorys, sodass private Abhängigkeiten während der Installation aufgelöst werden. Tokens sind nur schreibbar: um eines zu ändern, entferne den Eintrag und füge ihn erneut hinzu.",
542
542
  "entryNoun": "Registry-Eintrag",
543
543
  "list": {
544
544
  "heading": "Verbundene Registries",
545
545
  "tokenTail": "Token …{tail}",
546
- "remove": "Eintrag entfernen"
546
+ "remove": "Eintrag entfernen",
547
+ "noScopes": "Keine Scopes (das Token authentifiziert nur dieses Registry)"
547
548
  },
548
549
  "add": {
549
550
  "heading": "Eine Registry hinzufügen",
550
551
  "vendor": "Registry",
551
552
  "host": "Das Token wird nur an {host} gesendet.",
552
553
  "scopes": "Package-Scopes",
553
- "scopesHelp": "Kommagetrennte npm-Scopes",
554
+ "scopesHelp": "Optional. Kommagetrennte npm-Scopes.",
555
+ "scopesNote": "Optional. Ein hier eingetragener Scope leitet jedes Paket darunter an dieses Registry, sodass ein Scope, der auch öffentlich veröffentlichte Pakete enthält, nicht mehr aufgelöst wird. Lass das Feld leer, wenn das Repository eine eigene .npmrc enthält oder wenn einzelne Abhängigkeiten über ein benanntes Registry-Präfix wie gh: festgelegt werden (pnpm 11.1 oder neuer) - das Token authentifiziert das Registry ohnehin.",
554
556
  "token": "Zugriffstoken",
555
557
  "save": "Registry hinzufügen"
556
558
  },
@@ -2147,10 +2149,6 @@
2147
2149
  "label": "Post-Release-Health",
2148
2150
  "description": "Überwachen Sie Monitore und SLOs, nachdem ein Release ausgeliefert wurde (Datadog)."
2149
2151
  },
2150
- "packageRegistries": {
2151
- "label": "Private Package-Registries",
2152
- "description": "npm- und GitHub-Packages-Tokens, mit denen Agenten private Abhängigkeiten installieren."
2153
- },
2154
2152
  "apiTokens": {
2155
2153
  "label": "API-Zugriffstokens",
2156
2154
  "description": "Tokens, die externe Systeme vorlegen, um die cat-factory-API aufzurufen."
@@ -2163,10 +2163,6 @@
2163
2163
  "label": "Post-release health",
2164
2164
  "description": "Watch monitors and SLOs after a release ships (Datadog)."
2165
2165
  },
2166
- "packageRegistries": {
2167
- "label": "Private package registries",
2168
- "description": "npm and GitHub Packages tokens agents use to install private dependencies."
2169
- },
2170
2166
  "apiTokens": {
2171
2167
  "label": "API access tokens",
2172
2168
  "description": "Tokens external systems present to call the cat-factory API."
@@ -2813,20 +2809,22 @@
2813
2809
  "incidentNoun": "the incident provider"
2814
2810
  },
2815
2811
  "packageRegistries": {
2816
- "title": "Private package registries",
2812
+ "tab": "Package registries",
2817
2813
  "intro": "Connect the private registries your repositories install from. Agents receive them when they check out a repository, so private dependencies resolve during installs. Tokens are write-only: to change one, remove the entry and add it again.",
2818
2814
  "entryNoun": "registry entry",
2819
2815
  "list": {
2820
2816
  "heading": "Connected registries",
2821
2817
  "tokenTail": "token …{tail}",
2822
- "remove": "Remove entry"
2818
+ "remove": "Remove entry",
2819
+ "noScopes": "No scopes (the token authenticates this registry only)"
2823
2820
  },
2824
2821
  "add": {
2825
2822
  "heading": "Add a registry",
2826
2823
  "vendor": "Registry",
2827
2824
  "host": "The token is sent only to {host}.",
2828
2825
  "scopes": "Package scopes",
2829
- "scopesHelp": "Comma-separated npm scopes",
2826
+ "scopesHelp": "Optional. Comma-separated npm scopes.",
2827
+ "scopesNote": "Optional. A scope listed here routes every package under it to this registry, so a scope that also holds publicly published packages stops resolving. Leave it empty when the repository commits its own .npmrc, or when single dependencies are pinned with a named-registry prefix such as gh: (pnpm 11.1 or newer) - the token authenticates the registry either way.",
2830
2828
  "token": "Access token",
2831
2829
  "save": "Add registry"
2832
2830
  },