@cat-factory/app 0.59.2 → 0.60.1

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.
@@ -0,0 +1,339 @@
1
+ <script setup lang="ts">
2
+ // The kube ENGINE connection form for a `kubernetes` provision-type handler — the "how"
3
+ // (apiserver + TLS + namespace + URL derivation), split from the service-owned manifest
4
+ // source (the "what/where", configured on the service in ServiceTestConfig). Serves both the
5
+ // `local-k3s` and `remote-kubernetes` engines (they share `kubernetesEngineConfigSchema`); the
6
+ // parent passes the selected engine and gets back the discriminated `{ engine, kubernetes }`
7
+ // config + the `apiToken` secret bundle. Distinct from KubernetesEnvironmentForm (the legacy
8
+ // single-connection backend, which still carries the manifest source inline).
9
+ import { computed, reactive, ref, watch } from 'vue'
10
+ import { KUBERNETES_ENV_TOKEN_SECRET_KEY } from '@cat-factory/contracts'
11
+ import type {
12
+ EnvironmentHandlerView,
13
+ InfraEngine,
14
+ InfraHandlerConfig,
15
+ } from '@cat-factory/contracts'
16
+
17
+ // The kube branch of the discriminated handler config this form produces (the `local-k3s` /
18
+ // `remote-kubernetes` engines share `kubernetesEngineConfigSchema`). Emitting this typed
19
+ // (rather than a bare `Record`) lets the parent pass it straight to registerHandler with no
20
+ // `as never` cast, so a wrong config shape is caught at the call site instead of server-side.
21
+ type KubeHandlerConfig = Extract<InfraHandlerConfig, { engine: 'local-k3s' | 'remote-kubernetes' }>
22
+ type KubeHandlerPayload = { config: KubeHandlerConfig; secrets: Record<string, string> }
23
+
24
+ const props = defineProps<{
25
+ /** `local-k3s` or `remote-kubernetes` — the engine this handler is registered under. */
26
+ engine: Extract<InfraEngine, 'local-k3s' | 'remote-kubernetes'>
27
+ /** The registered handler (if any) — prefills every non-secret field on edit. */
28
+ handler: EnvironmentHandlerView | null
29
+ supportsTest: boolean
30
+ testing: boolean
31
+ busy: boolean
32
+ testResult: { ok: boolean; message?: string } | null
33
+ }>()
34
+
35
+ const emit = defineEmits<{
36
+ test: [payload: KubeHandlerPayload]
37
+ save: [payload: KubeHandlerPayload]
38
+ }>()
39
+
40
+ const { t } = useI18n()
41
+
42
+ type UrlSource =
43
+ | 'ingressTemplate'
44
+ | 'ingressStatus'
45
+ | 'serviceStatus'
46
+ | 'gatewayStatus'
47
+ | 'httpRouteStatus'
48
+
49
+ const form = reactive({
50
+ label: '',
51
+ apiServerUrl: '',
52
+ caCertPem: '',
53
+ insecureSkipTlsVerify: false,
54
+ namespaceTemplate: '',
55
+ imageTemplate: '',
56
+ urlSource: 'ingressTemplate' as UrlSource,
57
+ hostTemplate: '',
58
+ ingressName: '',
59
+ serviceName: '',
60
+ servicePort: '',
61
+ gatewayName: '',
62
+ httpRouteName: '',
63
+ urlScheme: '' as '' | 'http' | 'https',
64
+ })
65
+ const apiToken = ref('')
66
+
67
+ const urlSourceItems = computed(() => [
68
+ {
69
+ label: t('settings.infrastructure.kubernetesEngine.urlIngressTemplate'),
70
+ value: 'ingressTemplate',
71
+ },
72
+ { label: t('settings.infrastructure.kubernetesEngine.urlIngressStatus'), value: 'ingressStatus' },
73
+ { label: t('settings.infrastructure.kubernetesEngine.urlServiceStatus'), value: 'serviceStatus' },
74
+ { label: t('settings.infrastructure.kubernetesEngine.urlGatewayStatus'), value: 'gatewayStatus' },
75
+ {
76
+ label: t('settings.infrastructure.kubernetesEngine.urlHttpRouteStatus'),
77
+ value: 'httpRouteStatus',
78
+ },
79
+ ])
80
+ const schemeItems = computed(() => [
81
+ { label: t('settings.infrastructure.kubernetesEngine.schemeDefault'), value: '' },
82
+ { label: 'https', value: 'https' },
83
+ { label: 'http', value: 'http' },
84
+ ])
85
+
86
+ // Prefill every non-secret field from a registered handler's stored config (never the token —
87
+ // secrets are write-only and re-entered on update), so an edit changes one field without
88
+ // re-typing the form.
89
+ watch(
90
+ () => props.handler,
91
+ (h) => {
92
+ const cfg = h?.config
93
+ if (!cfg || (cfg.engine !== 'local-k3s' && cfg.engine !== 'remote-kubernetes')) return
94
+ const k = cfg.kubernetes as Record<string, unknown>
95
+ form.label = typeof k.label === 'string' ? k.label : ''
96
+ form.apiServerUrl = typeof k.apiServerUrl === 'string' ? k.apiServerUrl : ''
97
+ form.caCertPem = typeof k.caCertPem === 'string' ? k.caCertPem : ''
98
+ form.insecureSkipTlsVerify = k.insecureSkipTlsVerify === true
99
+ form.namespaceTemplate = typeof k.namespaceTemplate === 'string' ? k.namespaceTemplate : ''
100
+ form.imageTemplate = typeof k.imageTemplate === 'string' ? k.imageTemplate : ''
101
+ const url = k.url as Record<string, unknown> | undefined
102
+ const src = typeof url?.source === 'string' ? (url.source as UrlSource) : 'ingressTemplate'
103
+ form.urlSource = src
104
+ form.hostTemplate = typeof url?.hostTemplate === 'string' ? url.hostTemplate : ''
105
+ form.ingressName = typeof url?.ingressName === 'string' ? url.ingressName : ''
106
+ form.serviceName = typeof url?.serviceName === 'string' ? url.serviceName : ''
107
+ form.servicePort = typeof url?.port === 'number' ? String(url.port) : ''
108
+ form.gatewayName = typeof url?.gatewayName === 'string' ? url.gatewayName : ''
109
+ form.httpRouteName = typeof url?.httpRouteName === 'string' ? url.httpRouteName : ''
110
+ if (url?.scheme === 'http' || url?.scheme === 'https') form.urlScheme = url.scheme
111
+ },
112
+ { immediate: true },
113
+ )
114
+
115
+ const servicePortValid = computed(() => {
116
+ const raw = form.servicePort.trim()
117
+ if (!raw) return true
118
+ const port = Number(raw)
119
+ return Number.isInteger(port) && port >= 1 && port <= 65535
120
+ })
121
+ const urlValid = computed(() => {
122
+ if (form.urlSource === 'ingressTemplate') return !!form.hostTemplate.trim()
123
+ if (form.urlSource === 'serviceStatus') return !!form.serviceName.trim() && servicePortValid.value
124
+ return true // ingressStatus / gatewayStatus / httpRouteStatus have no required field
125
+ })
126
+
127
+ const connected = computed(() => !!props.handler)
128
+ const canSave = computed(
129
+ () =>
130
+ !!form.label.trim() && !!form.apiServerUrl.trim() && !!apiToken.value.trim() && urlValid.value,
131
+ )
132
+
133
+ function buildUrl(): Record<string, unknown> {
134
+ const url: Record<string, unknown> = { source: form.urlSource }
135
+ if (form.urlSource === 'ingressTemplate') {
136
+ url.hostTemplate = form.hostTemplate.trim()
137
+ } else if (form.urlSource === 'ingressStatus') {
138
+ if (form.ingressName.trim()) url.ingressName = form.ingressName.trim()
139
+ } else if (form.urlSource === 'serviceStatus') {
140
+ url.serviceName = form.serviceName.trim()
141
+ const port = Number(form.servicePort)
142
+ if (form.servicePort.trim() && Number.isInteger(port)) url.port = port
143
+ } else if (form.urlSource === 'gatewayStatus') {
144
+ if (form.gatewayName.trim()) url.gatewayName = form.gatewayName.trim()
145
+ } else {
146
+ if (form.httpRouteName.trim()) url.httpRouteName = form.httpRouteName.trim()
147
+ }
148
+ if (form.urlScheme) url.scheme = form.urlScheme
149
+ return url
150
+ }
151
+
152
+ function buildPayload(): KubeHandlerPayload {
153
+ const kubernetes: Record<string, unknown> = {
154
+ label: form.label.trim(),
155
+ apiServerUrl: form.apiServerUrl.trim(),
156
+ url: buildUrl(),
157
+ }
158
+ if (form.caCertPem.trim()) kubernetes.caCertPem = form.caCertPem.trim()
159
+ if (form.insecureSkipTlsVerify) kubernetes.insecureSkipTlsVerify = true
160
+ if (form.namespaceTemplate.trim()) kubernetes.namespaceTemplate = form.namespaceTemplate.trim()
161
+ if (form.imageTemplate.trim()) kubernetes.imageTemplate = form.imageTemplate.trim()
162
+ // One honest assertion at the boundary that actually builds the shape (the reactive form is
163
+ // dynamically assembled, then validated server-side); the emitted config flows typed onward.
164
+ return {
165
+ config: { engine: props.engine, kubernetes } as unknown as KubeHandlerConfig,
166
+ secrets: { [KUBERNETES_ENV_TOKEN_SECRET_KEY]: apiToken.value.trim() },
167
+ }
168
+ }
169
+
170
+ function optional(label: string): string {
171
+ return t('settings.providerConnection.form.optionalLabel', { label })
172
+ }
173
+ </script>
174
+
175
+ <template>
176
+ <div class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3">
177
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
178
+ {{
179
+ connected
180
+ ? t('settings.providerConnection.form.updateConfiguration')
181
+ : t('settings.providerConnection.form.connect')
182
+ }}
183
+ </p>
184
+
185
+ <UFormField :label="t('settings.infrastructure.kubernetesEngine.label')">
186
+ <UInput
187
+ v-model="form.label"
188
+ :placeholder="t('settings.infrastructure.kubernetesEngine.labelPlaceholder')"
189
+ />
190
+ </UFormField>
191
+
192
+ <UFormField
193
+ :label="t('settings.infrastructure.kubernetesEngine.apiServerUrl')"
194
+ :help="t('settings.infrastructure.kubernetesEngine.apiServerUrlHelp')"
195
+ >
196
+ <UInput v-model="form.apiServerUrl" class="font-mono" placeholder="https://10.0.0.1:6443" />
197
+ </UFormField>
198
+
199
+ <UFormField
200
+ :label="t('settings.infrastructure.kubernetesEngine.apiToken')"
201
+ :help="t('settings.infrastructure.kubernetesEngine.apiTokenHelp')"
202
+ >
203
+ <UInput v-model="apiToken" type="password" class="font-mono" autocomplete="off" />
204
+ </UFormField>
205
+
206
+ <!-- URL derivation: how the live environment URL is resolved once the service's
207
+ manifests are applied. -->
208
+ <UFormField :label="t('settings.infrastructure.kubernetesEngine.urlSourceLabel')">
209
+ <USelect v-model="form.urlSource" :items="urlSourceItems" />
210
+ </UFormField>
211
+
212
+ <UFormField
213
+ v-if="form.urlSource === 'ingressTemplate'"
214
+ :label="t('settings.infrastructure.kubernetesEngine.hostTemplate')"
215
+ :help="t('settings.infrastructure.kubernetesEngine.hostTemplateHelp')"
216
+ >
217
+ <UInput
218
+ v-model="form.hostTemplate"
219
+ class="font-mono"
220
+ placeholder="{{branch}}.preview.example.com"
221
+ />
222
+ </UFormField>
223
+
224
+ <UFormField
225
+ v-if="form.urlSource === 'ingressStatus'"
226
+ :label="optional(t('settings.infrastructure.kubernetesEngine.ingressName'))"
227
+ >
228
+ <UInput v-model="form.ingressName" class="font-mono" />
229
+ </UFormField>
230
+
231
+ <UFormField
232
+ v-if="form.urlSource === 'serviceStatus'"
233
+ :label="t('settings.infrastructure.kubernetesEngine.serviceName')"
234
+ >
235
+ <UInput v-model="form.serviceName" class="font-mono" />
236
+ </UFormField>
237
+ <UFormField
238
+ v-if="form.urlSource === 'serviceStatus'"
239
+ :label="optional(t('settings.infrastructure.kubernetesEngine.port'))"
240
+ >
241
+ <UInput
242
+ v-model="form.servicePort"
243
+ type="number"
244
+ :min="1"
245
+ :max="65535"
246
+ class="font-mono"
247
+ placeholder="80"
248
+ />
249
+ </UFormField>
250
+
251
+ <UFormField
252
+ v-if="form.urlSource === 'gatewayStatus'"
253
+ :label="optional(t('settings.infrastructure.kubernetesEngine.gatewayName'))"
254
+ >
255
+ <UInput v-model="form.gatewayName" class="font-mono" />
256
+ </UFormField>
257
+
258
+ <UFormField
259
+ v-if="form.urlSource === 'httpRouteStatus'"
260
+ :label="optional(t('settings.infrastructure.kubernetesEngine.httpRouteName'))"
261
+ >
262
+ <UInput v-model="form.httpRouteName" class="font-mono" />
263
+ </UFormField>
264
+
265
+ <UFormField :label="optional(t('settings.infrastructure.kubernetesEngine.scheme'))">
266
+ <USelect v-model="form.urlScheme" :items="schemeItems" />
267
+ </UFormField>
268
+
269
+ <!-- Optional refinements. -->
270
+ <UFormField
271
+ :label="optional(t('settings.infrastructure.kubernetesEngine.namespaceTemplate'))"
272
+ :help="t('settings.infrastructure.kubernetesEngine.namespaceTemplateHelp')"
273
+ >
274
+ <UInput
275
+ v-model="form.namespaceTemplate"
276
+ class="font-mono"
277
+ placeholder="cf-env-{{pullNumber}}"
278
+ />
279
+ </UFormField>
280
+
281
+ <UFormField
282
+ :label="optional(t('settings.infrastructure.kubernetesEngine.imageTemplate'))"
283
+ :help="t('settings.infrastructure.kubernetesEngine.imageTemplateHelp')"
284
+ >
285
+ <UInput v-model="form.imageTemplate" class="font-mono" />
286
+ </UFormField>
287
+
288
+ <UFormField
289
+ :label="optional(t('settings.infrastructure.kubernetesEngine.caCertPem'))"
290
+ :help="t('settings.infrastructure.kubernetesEngine.caCertPemHelp')"
291
+ >
292
+ <UTextarea
293
+ v-model="form.caCertPem"
294
+ :rows="3"
295
+ class="font-mono"
296
+ placeholder="-----BEGIN CERTIFICATE-----"
297
+ />
298
+ </UFormField>
299
+
300
+ <UFormField :help="t('settings.infrastructure.kubernetesEngine.insecureSkipTlsVerifyHelp')">
301
+ <UCheckbox
302
+ v-model="form.insecureSkipTlsVerify"
303
+ :label="t('settings.infrastructure.kubernetesEngine.insecureSkipTlsVerify')"
304
+ />
305
+ </UFormField>
306
+
307
+ <div v-if="supportsTest" class="flex items-center gap-2">
308
+ <UButton
309
+ color="neutral"
310
+ variant="soft"
311
+ size="sm"
312
+ icon="i-lucide-plug-zap"
313
+ :loading="testing"
314
+ :disabled="!canSave"
315
+ @click="emit('test', buildPayload())"
316
+ >
317
+ {{ t('settings.providerConnection.test.button') }}
318
+ </UButton>
319
+ <span v-if="testResult && testResult.ok" class="text-xs text-emerald-400">
320
+ {{ testResult.message ?? t('settings.providerConnection.test.ok') }}
321
+ </span>
322
+ <span v-else-if="testResult" class="text-xs text-rose-400">
323
+ {{ testResult.message ?? t('settings.providerConnection.test.failed') }}
324
+ </span>
325
+ </div>
326
+
327
+ <div class="flex justify-end">
328
+ <UButton
329
+ color="primary"
330
+ size="sm"
331
+ :loading="busy"
332
+ :disabled="!canSave"
333
+ @click="emit('save', buildPayload())"
334
+ >
335
+ {{ connected ? t('common.save') : t('settings.providerConnection.form.connect') }}
336
+ </UButton>
337
+ </div>
338
+ </div>
339
+ </template>
@@ -0,0 +1,89 @@
1
+ import {
2
+ listEnvironmentHandlersContract,
3
+ listEnvironmentUserHandlersContract,
4
+ registerEnvironmentHandlerContract,
5
+ removeCustomManifestTypeContract,
6
+ removeEnvironmentUserHandlerContract,
7
+ unregisterEnvironmentHandlerContract,
8
+ upsertCustomManifestTypeContract,
9
+ upsertEnvironmentUserHandlerContract,
10
+ } from '@cat-factory/contracts'
11
+ import type {
12
+ ProvisionType,
13
+ RegisterEnvironmentHandlerInput,
14
+ UpsertCustomManifestTypeInput,
15
+ UpsertEnvironmentUserHandlerBody,
16
+ } from '@cat-factory/contracts'
17
+ import type { ApiContext } from './context'
18
+
19
+ /**
20
+ * Per-provision-type infra HANDLER config (the workspace + per-user "how"): the batched
21
+ * handler bundle (every workspace handler + the custom-manifest-type catalog),
22
+ * register/remove for a workspace handler, custom-type CRUD, and — local mode only —
23
+ * the per-user override handlers (mounted at `/me/...`, so user-scoped, no `/workspaces`
24
+ * prefix; these 503 off the local facade). See EnvironmentController +
25
+ * EnvironmentUserHandlerController in @cat-factory/server.
26
+ */
27
+ export function infraHandlersApi({ send, ws }: ApiContext) {
28
+ return {
29
+ // ---- Workspace per-type handlers + custom-type catalog ------------------
30
+ listEnvironmentHandlers: (workspaceId: string) =>
31
+ send(listEnvironmentHandlersContract, { pathPrefix: ws(workspaceId) }),
32
+
33
+ registerEnvironmentHandler: (workspaceId: string, body: RegisterEnvironmentHandlerInput) =>
34
+ send(registerEnvironmentHandlerContract, { pathPrefix: ws(workspaceId), body }),
35
+
36
+ // `manifestId` (for a `custom` handler) rides as a query param; absent ⇒ the bare handler.
37
+ unregisterEnvironmentHandler: (
38
+ workspaceId: string,
39
+ provisionType: ProvisionType,
40
+ manifestId?: string,
41
+ ) =>
42
+ send(unregisterEnvironmentHandlerContract, {
43
+ pathPrefix: ws(workspaceId),
44
+ pathParams: { provisionType },
45
+ queryParams: { manifestId },
46
+ }),
47
+
48
+ upsertCustomManifestType: (
49
+ workspaceId: string,
50
+ manifestId: string,
51
+ body: UpsertCustomManifestTypeInput,
52
+ ) =>
53
+ send(upsertCustomManifestTypeContract, {
54
+ pathPrefix: ws(workspaceId),
55
+ pathParams: { manifestId },
56
+ body,
57
+ }),
58
+
59
+ removeCustomManifestType: (workspaceId: string, manifestId: string) =>
60
+ send(removeCustomManifestTypeContract, {
61
+ pathPrefix: ws(workspaceId),
62
+ pathParams: { manifestId },
63
+ }),
64
+
65
+ // ---- Per-USER override handlers (local mode; `/me/...`, no ws prefix) ----
66
+ listEnvironmentUserHandlers: (workspaceId: string) =>
67
+ send(listEnvironmentUserHandlersContract, { pathParams: { workspaceId } }),
68
+
69
+ upsertEnvironmentUserHandler: (
70
+ workspaceId: string,
71
+ provisionType: ProvisionType,
72
+ body: UpsertEnvironmentUserHandlerBody,
73
+ ) =>
74
+ send(upsertEnvironmentUserHandlerContract, {
75
+ pathParams: { workspaceId, provisionType },
76
+ body,
77
+ }),
78
+
79
+ removeEnvironmentUserHandler: (
80
+ workspaceId: string,
81
+ provisionType: ProvisionType,
82
+ manifestId?: string,
83
+ ) =>
84
+ send(removeEnvironmentUserHandlerContract, {
85
+ pathParams: { workspaceId, provisionType },
86
+ queryParams: { manifestId },
87
+ }),
88
+ }
89
+ }
@@ -12,6 +12,7 @@ import { fragmentsApi } from './api/fragments'
12
12
  import { githubApi } from './api/github'
13
13
  import { humanReviewApi } from './api/humanReview'
14
14
  import { humanTestApi } from './api/humanTest'
15
+ import { infraHandlersApi } from './api/infraHandlers'
15
16
  import { visualConfirmApi } from './api/visualConfirm'
16
17
  import { kaizenApi } from './api/kaizen'
17
18
  import { localSettingsApi } from './api/localSettings'
@@ -107,6 +108,7 @@ export function useApi() {
107
108
  ...notificationsApi(ctx),
108
109
  ...presetsApi(ctx),
109
110
  ...providerConnectionsApi(ctx),
111
+ ...infraHandlersApi(ctx),
110
112
  ...provisioningLogsApi(ctx),
111
113
  ...releaseHealthApi(ctx),
112
114
  ...recurringApi(ctx),
@@ -0,0 +1,172 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref, type Ref } from 'vue'
3
+ import type {
4
+ CustomManifestType,
5
+ EnvironmentHandlerView,
6
+ ProvisionType,
7
+ RegisterEnvironmentHandlerInput,
8
+ UpsertCustomManifestTypeInput,
9
+ UpsertEnvironmentUserHandlerBody,
10
+ } from '@cat-factory/contracts'
11
+ import { useWorkspaceStore } from '~/stores/workspace'
12
+
13
+ // One predicate for "this handler is the (type, manifestId) one" — custom handlers are keyed by
14
+ // manifestId, the rest by type alone (manifestId null). Defined once and reused across both the
15
+ // workspace and per-user handler sets so the match key lives in a single place.
16
+ const sameHandler = (h: EnvironmentHandlerView, type: ProvisionType, manifestId?: string | null) =>
17
+ h.provisionType === type && (h.manifestId ?? null) === (manifestId ?? null)
18
+
19
+ // Replace the matching entry in a handler-list ref, or append it.
20
+ function upsertInto(list: Ref<EnvironmentHandlerView[]>, saved: EnvironmentHandlerView) {
21
+ const idx = list.value.findIndex((h) => sameHandler(h, saved.provisionType, saved.manifestId))
22
+ if (idx >= 0) list.value[idx] = saved
23
+ else list.value.push(saved)
24
+ }
25
+
26
+ /**
27
+ * The per-provision-type infra handlers (the workspace + per-user "how"): for each provision
28
+ * type a service can declare, which engine + connection the workspace stands its environment
29
+ * up with, plus the open custom-manifest-type catalog. Loaded on demand (the Infrastructure
30
+ * window's per-type configurator + the service inspector's custom-type picker), not from the
31
+ * snapshot, since the secret bundles never leave the server.
32
+ *
33
+ * The per-USER override handlers (`userHandlers`) are local mode only — the backend service is
34
+ * wired solely by the local facade, so the endpoints 503 elsewhere and `userOverridesAvailable`
35
+ * stays false (the override affordance hides).
36
+ */
37
+ export const useInfraConfigStore = defineStore('infraConfig', () => {
38
+ const api = useApi()
39
+
40
+ const handlers = ref<EnvironmentHandlerView[]>([])
41
+ const customTypes = ref<CustomManifestType[]>([])
42
+ const userHandlers = ref<EnvironmentHandlerView[]>([])
43
+ const loading = ref(false)
44
+ // `null` until first probed; `false` ⇒ the test-env handler integration is off (503),
45
+ // so the configurator hides. Mirrors the other infra stores' availability gate.
46
+ const available = ref<boolean | null>(null)
47
+ // Per-user overrides probe independently (local mode only).
48
+ const userOverridesAvailable = ref<boolean | null>(null)
49
+ let inFlight: Promise<void> | null = null
50
+
51
+ /** Force a refresh of the workspace handler bundle (used after a save/remove). */
52
+ async function load() {
53
+ const ws = useWorkspaceStore()
54
+ loading.value = true
55
+ try {
56
+ const bundle = await api.listEnvironmentHandlers(ws.requireId())
57
+ handlers.value = bundle.handlers
58
+ customTypes.value = bundle.customTypes
59
+ available.value = true
60
+ } catch {
61
+ // 503 (environments integration off) or any error → hide the configurator.
62
+ available.value = false
63
+ handlers.value = []
64
+ customTypes.value = []
65
+ } finally {
66
+ loading.value = false
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Load once and share the result: repeated window opens / inspector mounts reuse the
72
+ * resolved state (and coalesce a concurrent in-flight request). Use `load()` to force a
73
+ * refresh. The custom-type catalog feeds the service inspector's `custom` picker, so this
74
+ * is also called there (cheaply) to populate it.
75
+ */
76
+ async function ensureLoaded() {
77
+ if (available.value !== null) return
78
+ if (!inFlight) inFlight = load().finally(() => (inFlight = null))
79
+ return inFlight
80
+ }
81
+
82
+ /** The workspace handler matching a provision type (+ manifest id, for `custom`), if registered. */
83
+ function handlerFor(
84
+ type: ProvisionType,
85
+ manifestId?: string | null,
86
+ ): EnvironmentHandlerView | undefined {
87
+ return handlers.value.find((h) => sameHandler(h, type, manifestId))
88
+ }
89
+
90
+ async function registerHandler(input: RegisterEnvironmentHandlerInput) {
91
+ const ws = useWorkspaceStore()
92
+ const saved = await api.registerEnvironmentHandler(ws.requireId(), input)
93
+ upsertInto(handlers, saved)
94
+ return saved
95
+ }
96
+
97
+ async function unregisterHandler(type: ProvisionType, manifestId?: string | null) {
98
+ const ws = useWorkspaceStore()
99
+ await api.unregisterEnvironmentHandler(ws.requireId(), type, manifestId ?? undefined)
100
+ handlers.value = handlers.value.filter((h) => !sameHandler(h, type, manifestId))
101
+ }
102
+
103
+ // ---- Custom-manifest-type catalog CRUD (workspace-defined entries only) ----
104
+ async function upsertCustomType(manifestId: string, input: UpsertCustomManifestTypeInput) {
105
+ const ws = useWorkspaceStore()
106
+ const saved = await api.upsertCustomManifestType(ws.requireId(), manifestId, input)
107
+ const idx = customTypes.value.findIndex((t) => t.manifestId === saved.manifestId)
108
+ if (idx >= 0) customTypes.value[idx] = saved
109
+ else customTypes.value.push(saved)
110
+ return saved
111
+ }
112
+
113
+ async function removeCustomType(manifestId: string) {
114
+ const ws = useWorkspaceStore()
115
+ await api.removeCustomManifestType(ws.requireId(), manifestId)
116
+ customTypes.value = customTypes.value.filter((t) => t.manifestId !== manifestId)
117
+ }
118
+
119
+ // ---- Per-user override handlers (local mode) ------------------------------
120
+ async function loadUserHandlers() {
121
+ const ws = useWorkspaceStore()
122
+ try {
123
+ const { handlers: list } = await api.listEnvironmentUserHandlers(ws.requireId())
124
+ userHandlers.value = list
125
+ userOverridesAvailable.value = true
126
+ } catch {
127
+ // 503 (not the local facade) / not signed in → no per-user overrides surface.
128
+ userOverridesAvailable.value = false
129
+ userHandlers.value = []
130
+ }
131
+ }
132
+
133
+ function userHandlerFor(
134
+ type: ProvisionType,
135
+ manifestId?: string | null,
136
+ ): EnvironmentHandlerView | undefined {
137
+ return userHandlers.value.find((h) => sameHandler(h, type, manifestId))
138
+ }
139
+
140
+ async function upsertUserHandler(type: ProvisionType, body: UpsertEnvironmentUserHandlerBody) {
141
+ const ws = useWorkspaceStore()
142
+ const saved = await api.upsertEnvironmentUserHandler(ws.requireId(), type, body)
143
+ upsertInto(userHandlers, saved)
144
+ return saved
145
+ }
146
+
147
+ async function removeUserHandler(type: ProvisionType, manifestId?: string | null) {
148
+ const ws = useWorkspaceStore()
149
+ await api.removeEnvironmentUserHandler(ws.requireId(), type, manifestId ?? undefined)
150
+ userHandlers.value = userHandlers.value.filter((h) => !sameHandler(h, type, manifestId))
151
+ }
152
+
153
+ return {
154
+ handlers,
155
+ customTypes,
156
+ userHandlers,
157
+ loading,
158
+ available,
159
+ userOverridesAvailable,
160
+ load,
161
+ ensureLoaded,
162
+ handlerFor,
163
+ registerHandler,
164
+ unregisterHandler,
165
+ upsertCustomType,
166
+ removeCustomType,
167
+ loadUserHandlers,
168
+ userHandlerFor,
169
+ upsertUserHandler,
170
+ removeUserHandler,
171
+ }
172
+ })
@@ -28,6 +28,7 @@ export type {
28
28
  CloudProvider,
29
29
  InstanceSize,
30
30
  ProvisionType,
31
+ ServiceProvisioning,
31
32
  AgentConfigOption,
32
33
  AgentConfigDescriptor,
33
34
  TestConcernSeverity,