@cat-factory/app 0.133.0 → 0.135.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/app/components/environments/EnvironmentSetupWizard.vue +70 -632
  2. package/app/components/environments/steps/EnvPickStep.vue +40 -0
  3. package/app/components/environments/steps/EnvPreflightStep.vue +115 -0
  4. package/app/components/environments/steps/EnvReviewStep.vue +381 -0
  5. package/app/components/environments/steps/EnvSaveStep.vue +109 -0
  6. package/app/components/environments/steps/JourneyStepNav.vue +30 -0
  7. package/app/components/panels/StepResultViewHost.vue +48 -86
  8. package/app/modular/agent-kinds.spec.ts +80 -0
  9. package/app/modular/agent-kinds.ts +83 -0
  10. package/app/modular/journeys/environmentSetup.frame.ts +31 -0
  11. package/app/modular/journeys/environmentSetup.logic.spec.ts +34 -0
  12. package/app/modular/journeys/environmentSetup.logic.ts +86 -0
  13. package/app/modular/journeys/environmentSetup.ts +143 -0
  14. package/app/modular/journeys/persistence.spec.ts +68 -0
  15. package/app/modular/journeys/persistence.ts +58 -0
  16. package/app/modular/nav-contributions.spec.ts +1 -1
  17. package/app/modular/nav-contributions.ts +7 -11
  18. package/app/modular/registry.spec.ts +38 -0
  19. package/app/modular/registry.ts +30 -13
  20. package/app/modular/result-views.ts +104 -0
  21. package/app/modular/slots.ts +40 -0
  22. package/app/plugins/modular.client.ts +59 -3
  23. package/app/stores/agents.spec.ts +100 -0
  24. package/app/stores/agents.ts +115 -34
  25. package/app/stores/environmentWizard.ts +23 -27
  26. package/app/stores/workspace.ts +4 -3
  27. package/app/types/domain.ts +4 -3
  28. package/app/utils/catalog.ts +57 -16
  29. package/package.json +13 -12
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ // The environment-setup journey's PICK step (slice 3 of the modular-vue adoption).
3
+ // Lists the workspace's service frames; choosing one fires the journey's `next`
4
+ // exit carrying the frame id, which the transition folds into journey state and
5
+ // advances to the review step. Rendered by `JourneyOutlet`, so it receives the
6
+ // `ModuleEntryProps` (`input` / `exit` / `goBack`) as attributes.
7
+ import type { EnvSelectOutput } from '~/modular/journeys/environmentSetup.logic'
8
+
9
+ defineProps<{
10
+ input: { frameId: string | null }
11
+ exit: (name: 'select', output: EnvSelectOutput) => void
12
+ }>()
13
+
14
+ const store = useEnvironmentWizardStore()
15
+ const { t } = useI18n()
16
+ </script>
17
+
18
+ <template>
19
+ <section class="space-y-3" data-testid="env-setup-step-pick">
20
+ <p class="text-sm text-slate-400">{{ t('environmentWizard.pick.intro') }}</p>
21
+ <p v-if="!store.serviceFrames.length" class="text-sm text-slate-500">
22
+ {{ t('environmentWizard.pick.empty') }}
23
+ </p>
24
+ <div v-else class="space-y-1.5">
25
+ <UButton
26
+ v-for="frame in store.serviceFrames"
27
+ :key="frame.id"
28
+ block
29
+ color="neutral"
30
+ variant="soft"
31
+ class="justify-start"
32
+ icon="i-lucide-box"
33
+ :data-testid="`env-setup-frame-${frame.id}`"
34
+ @click="exit('select', { frameId: frame.id })"
35
+ >
36
+ {{ frame.title }}
37
+ </UButton>
38
+ </div>
39
+ </section>
40
+ </template>
@@ -0,0 +1,115 @@
1
+ <script setup lang="ts">
2
+ // The environment-setup journey's PREFLIGHT step (slice 3 of the modular-vue
3
+ // adoption). Runs the working recipe's declared host preflight checks (degrades
4
+ // on a non-local facade) and lets the operator advance to save. `next` is always
5
+ // allowed — preflights are advisory, not a hard gate.
6
+ import { computed } from 'vue'
7
+ import JourneyStepNav from '~/components/environments/steps/JourneyStepNav.vue'
8
+ import { useEnvironmentWizardTarget } from '~/modular/journeys/environmentSetup.frame'
9
+
10
+ const props = defineProps<{
11
+ input: { frameId: string | null }
12
+ exit: (name: 'advance') => void
13
+ goBack?: () => void
14
+ }>()
15
+
16
+ const store = useEnvironmentWizardStore()
17
+ const preflights = usePreflightsStore()
18
+ const { t } = useI18n()
19
+
20
+ // Keep the data store pointed at THIS step's frame — a resume that lands here
21
+ // (or a prior open of a different frame) must not leave preflight reading another
22
+ // frame's recipe. Idempotent per frame; see the composable.
23
+ useEnvironmentWizardTarget(() => props.input.frameId)
24
+
25
+ // The host-probe runtime isn't wired (a non-local facade 503'd) — the checklist degrades to a note.
26
+ const preflightsUnavailable = computed(() => preflights.available === false)
27
+
28
+ const PREFLIGHT_COLOR: Record<'pass' | 'warn' | 'fail', 'success' | 'warning' | 'error'> = {
29
+ pass: 'success',
30
+ warn: 'warning',
31
+ fail: 'error',
32
+ }
33
+ </script>
34
+
35
+ <template>
36
+ <section class="space-y-3" data-testid="env-setup-step-preflight">
37
+ <div class="flex items-center justify-between gap-2">
38
+ <p class="text-sm text-slate-400">{{ t('environmentWizard.preflight.intro') }}</p>
39
+ <UButton
40
+ size="xs"
41
+ variant="soft"
42
+ color="primary"
43
+ icon="i-lucide-list-checks"
44
+ :loading="store.preflightRunning"
45
+ data-testid="env-setup-preflight-run"
46
+ @click="store.runPreflight()"
47
+ >
48
+ {{ t('environmentWizard.preflight.run') }}
49
+ </UButton>
50
+ </div>
51
+
52
+ <p
53
+ v-if="!store.recipe.prerequisites?.length"
54
+ class="text-[12px] text-slate-500"
55
+ data-testid="env-setup-preflight-none"
56
+ >
57
+ {{ t('environmentWizard.preflight.none') }}
58
+ </p>
59
+ <p
60
+ v-else-if="preflightsUnavailable"
61
+ class="text-[12px] text-amber-300/80"
62
+ data-testid="env-setup-preflight-unavailable"
63
+ >
64
+ {{ t('environmentWizard.preflight.unavailable') }}
65
+ </p>
66
+ <p
67
+ v-if="store.preflightError"
68
+ class="text-[12px] text-rose-300/80"
69
+ data-testid="env-setup-preflight-error"
70
+ >
71
+ {{ store.preflightError }}
72
+ </p>
73
+
74
+ <ul
75
+ v-if="store.preflightResults?.length"
76
+ class="space-y-2"
77
+ data-testid="env-setup-preflight-results"
78
+ >
79
+ <li
80
+ v-for="r in store.preflightResults"
81
+ :key="r.title"
82
+ class="rounded border border-slate-800 bg-slate-900/40 p-2"
83
+ >
84
+ <div class="flex items-center gap-2">
85
+ <UBadge :color="PREFLIGHT_COLOR[r.status]" variant="subtle" size="sm">
86
+ {{ r.status }}
87
+ </UBadge>
88
+ <span class="text-[12px] text-slate-200">{{ r.title }}</span>
89
+ <span v-if="!r.required" class="ms-auto text-[10px] text-slate-500">
90
+ {{ t('environmentWizard.preflight.optional') }}
91
+ </span>
92
+ </div>
93
+ <p v-if="r.detail" class="mt-1 text-[11px] text-slate-400">{{ r.detail }}</p>
94
+ <pre
95
+ v-if="r.status !== 'pass' && r.remediation"
96
+ class="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded border border-amber-900/40 bg-amber-950/20 p-1.5 text-[11px] text-amber-200/90"
97
+ >{{ r.remediation }}</pre
98
+ >
99
+ </li>
100
+ </ul>
101
+
102
+ <JourneyStepNav :go-back="goBack">
103
+ <template #primary>
104
+ <UButton
105
+ color="primary"
106
+ trailing-icon="i-lucide-arrow-right"
107
+ data-testid="env-setup-next"
108
+ @click="exit('advance')"
109
+ >
110
+ {{ t('common.next') }}
111
+ </UButton>
112
+ </template>
113
+ </JourneyStepNav>
114
+ </section>
115
+ </template>
@@ -0,0 +1,381 @@
1
+ <script setup lang="ts">
2
+ // The environment-setup journey's REVIEW step (slice 3 of the modular-vue adoption).
3
+ // Seeds the data layer for the journey's target frame (idempotent per frame, so
4
+ // back-navigation doesn't clobber edits), then presents the detected + optionally
5
+ // analyst-augmented `docker-compose` recipe for the operator to confirm/edit.
6
+ // Firing the journey's `advance` exit (gated until a recommendation + exposed
7
+ // service exist) advances to the preflight step.
8
+ import { computed, ref } from 'vue'
9
+ import type {
10
+ MergeableRecipeField,
11
+ ProvisioningComposeFileCandidate,
12
+ ProvisioningProfileCandidate,
13
+ ProvisioningSeedDumpCandidate,
14
+ } from '@cat-factory/contracts'
15
+ import JourneyStepNav from '~/components/environments/steps/JourneyStepNav.vue'
16
+ import { useEnvironmentWizardTarget } from '~/modular/journeys/environmentSetup.frame'
17
+
18
+ const props = defineProps<{
19
+ input: { frameId: string | null }
20
+ exit: (name: 'advance') => void
21
+ goBack?: () => void
22
+ }>()
23
+
24
+ const store = useEnvironmentWizardStore()
25
+ const { t } = useI18n()
26
+
27
+ // Bridge the journey's target frame into the data store (idempotent per frame,
28
+ // so re-entry / back-nav / resume keeps in-progress edits). See the composable.
29
+ useEnvironmentWizardTarget(() => props.input.frameId)
30
+
31
+ // ---- Provenance -------------------------------------------------------------
32
+ const FIELD_LABEL = computed<Record<MergeableRecipeField, string>>(() => ({
33
+ composeFiles: t('environmentWizard.field.composeFiles'),
34
+ composeProfiles: t('environmentWizard.field.composeProfiles'),
35
+ envFiles: t('environmentWizard.field.envFiles'),
36
+ externalNetworks: t('environmentWizard.field.externalNetworks'),
37
+ sharedStackRefs: t('environmentWizard.field.sharedStackRefs'),
38
+ prerequisites: t('environmentWizard.field.prerequisites'),
39
+ setupSteps: t('environmentWizard.field.setupSteps'),
40
+ healthGate: t('environmentWizard.field.healthGate'),
41
+ teardownSteps: t('environmentWizard.field.teardownSteps'),
42
+ }))
43
+ const ORIGIN_COLOR: Record<'detector' | 'analyst' | 'both', 'success' | 'info' | 'warning'> = {
44
+ detector: 'success',
45
+ analyst: 'info',
46
+ both: 'warning',
47
+ }
48
+ const ORIGIN_LABEL = computed<Record<'detector' | 'analyst' | 'both', string>>(() => ({
49
+ detector: t('environmentWizard.origin.detector'),
50
+ analyst: t('environmentWizard.origin.analyst'),
51
+ both: t('environmentWizard.origin.both'),
52
+ }))
53
+
54
+ // ---- Candidate helpers ------------------------------------------------------
55
+ const composeFileCandidates = computed<ProvisioningComposeFileCandidate[]>(
56
+ () => store.recommendation?.composeFileCandidates ?? [],
57
+ )
58
+ const profileCandidates = computed<ProvisioningProfileCandidate[]>(
59
+ () => store.recommendation?.profileCandidates ?? [],
60
+ )
61
+ const seedDumpCandidates = computed<ProvisioningSeedDumpCandidate[]>(
62
+ () => store.recommendation?.seedDumpCandidates ?? [],
63
+ )
64
+ const composeServiceOptions = computed(() =>
65
+ (store.recommendation?.composeServiceCandidates ?? []).map((c) => ({
66
+ label: c.recommended ? `${c.service} · ${t('environmentWizard.recommended')}` : c.service,
67
+ value: c.service,
68
+ })),
69
+ )
70
+ // The repo ships its own imperative bring-up (a `bin/*console*` / Makefile / justfile the
71
+ // deterministic scan can't read) — the strongest signal to run the analyst, so when present we
72
+ // elevate the deep-analysis affordance from a quiet opt-in to a prominent nudge naming the CLI.
73
+ const repoCliHint = computed(() => store.recommendation?.repoCliHint)
74
+
75
+ function fileEnabled(path: string): boolean {
76
+ return store.recipe.composeFiles?.includes(path) ?? false
77
+ }
78
+ function profileEnabled(profile: string): boolean {
79
+ return store.recipe.composeProfiles?.includes(profile) ?? false
80
+ }
81
+ function seedAdded(path: string): boolean {
82
+ return (store.recipe.setupSteps ?? []).some(
83
+ (s) => s.kind === 'compose-exec' && s.stdinFile === path,
84
+ )
85
+ }
86
+
87
+ // ---- Raw recipe editor ------------------------------------------------------
88
+ const rawOpen = ref(false)
89
+ const rawText = ref('')
90
+ const rawError = ref<string | null>(null)
91
+ function openRaw() {
92
+ rawText.value = JSON.stringify(store.recipe, null, 2)
93
+ rawError.value = null
94
+ rawOpen.value = true
95
+ }
96
+ function toggleRaw() {
97
+ if (rawOpen.value) rawOpen.value = false
98
+ else openRaw()
99
+ }
100
+ function applyRaw() {
101
+ rawError.value = store.setRecipeFromJson(rawText.value)
102
+ if (!rawError.value) rawOpen.value = false
103
+ }
104
+
105
+ // ---- Step gating ------------------------------------------------------------
106
+ const canLeaveReview = computed(
107
+ () => !!store.recommendation && store.composeService.trim().length > 0,
108
+ )
109
+ </script>
110
+
111
+ <template>
112
+ <section class="space-y-4" data-testid="env-setup-step-review">
113
+ <!-- detection status -->
114
+ <div class="flex items-center justify-between gap-2">
115
+ <div class="min-w-0">
116
+ <p class="text-sm font-medium text-slate-200">{{ store.targetFrame?.title }}</p>
117
+ <p class="text-[11px] text-slate-500">{{ t('environmentWizard.review.detectHint') }}</p>
118
+ </div>
119
+ <UButton
120
+ size="xs"
121
+ variant="soft"
122
+ color="primary"
123
+ icon="i-lucide-wand-sparkles"
124
+ :loading="store.detecting"
125
+ :disabled="!store.hasRepo"
126
+ data-testid="env-setup-detect"
127
+ @click="store.detect()"
128
+ >
129
+ {{ t('environmentWizard.review.detect') }}
130
+ </UButton>
131
+ </div>
132
+
133
+ <p v-if="!store.hasRepo" class="text-[12px] text-amber-300/80">
134
+ {{ t('environmentWizard.review.noRepo') }}
135
+ </p>
136
+ <p
137
+ v-else-if="store.detectError"
138
+ class="text-[12px] text-rose-300/80"
139
+ data-testid="env-setup-detect-error"
140
+ >
141
+ {{ t('environmentWizard.review.detectError') }}
142
+ </p>
143
+
144
+ <template v-if="store.recommendation && !store.detecting">
145
+ <!-- deep analysis (opt-in; elevated to a prominent nudge when the repo ships its own CLI) -->
146
+ <div
147
+ class="rounded-md border p-3"
148
+ :class="
149
+ repoCliHint
150
+ ? 'border-primary-700/60 bg-primary-950/30'
151
+ : 'border-slate-800 bg-slate-900/40'
152
+ "
153
+ >
154
+ <p
155
+ v-if="repoCliHint"
156
+ class="mb-2 flex items-start gap-1.5 text-[11px] text-primary-300"
157
+ data-testid="env-setup-cli-nudge"
158
+ >
159
+ <UIcon name="i-lucide-lightbulb" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
160
+ <span>{{ t('environmentWizard.analysis.cliNudge', { path: repoCliHint.path }) }}</span>
161
+ </p>
162
+ <div class="flex items-center justify-between gap-2">
163
+ <div class="min-w-0">
164
+ <p class="text-[12px] font-medium text-slate-300">
165
+ {{ t('environmentWizard.analysis.title') }}
166
+ </p>
167
+ <p class="text-[11px] text-slate-500">{{ t('environmentWizard.analysis.hint') }}</p>
168
+ </div>
169
+ <UButton
170
+ size="xs"
171
+ variant="soft"
172
+ :color="repoCliHint ? 'primary' : 'neutral'"
173
+ icon="i-lucide-sparkles"
174
+ :loading="store.analysisStatus === 'running'"
175
+ :disabled="!store.canAnalyze || store.analysisStatus === 'running'"
176
+ data-testid="env-setup-run-analysis"
177
+ @click="store.startAnalysis()"
178
+ >
179
+ {{ t('environmentWizard.analysis.run') }}
180
+ </UButton>
181
+ </div>
182
+ <p
183
+ v-if="!store.canAnalyze"
184
+ class="mt-2 text-[11px] text-slate-500"
185
+ data-testid="env-setup-analysis-unavailable"
186
+ >
187
+ {{ t('environmentWizard.analysis.unavailable') }}
188
+ </p>
189
+ <p v-else-if="store.analysisStatus === 'failed'" class="mt-2 text-[11px] text-rose-300/80">
190
+ {{ t('environmentWizard.analysis.failed') }}
191
+ </p>
192
+ <div
193
+ v-else-if="store.analysisStatus === 'ready'"
194
+ class="mt-2 space-y-2"
195
+ data-testid="env-setup-analysis-ready"
196
+ >
197
+ <p v-if="store.merged?.summary" class="text-[11px] leading-snug text-slate-400">
198
+ {{ store.merged.summary }}
199
+ </p>
200
+ <UButton
201
+ size="xs"
202
+ variant="soft"
203
+ color="primary"
204
+ icon="i-lucide-git-merge"
205
+ data-testid="env-setup-apply-analysis"
206
+ @click="store.applyAnalystDraft()"
207
+ >
208
+ {{ t('environmentWizard.analysis.apply') }}
209
+ </UButton>
210
+ </div>
211
+ </div>
212
+
213
+ <!-- per-field provenance -->
214
+ <div v-if="store.merged?.fields.length" class="space-y-1.5">
215
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
216
+ {{ t('environmentWizard.review.provenanceTitle') }}
217
+ </p>
218
+ <div class="flex flex-wrap gap-1.5">
219
+ <UBadge
220
+ v-for="f in store.merged.fields"
221
+ :key="f.field"
222
+ :color="ORIGIN_COLOR[f.origin]"
223
+ variant="subtle"
224
+ size="sm"
225
+ :title="f.detectorMessage ?? f.analystRationale ?? ''"
226
+ :data-testid="`env-setup-field-${f.field}`"
227
+ >
228
+ {{ FIELD_LABEL[f.field] }} · {{ ORIGIN_LABEL[f.origin] }}
229
+ </UBadge>
230
+ </div>
231
+ </div>
232
+
233
+ <!-- exposed compose service + port -->
234
+ <div class="grid grid-cols-2 gap-3">
235
+ <UFormField :label="t('environmentWizard.review.service')" required>
236
+ <USelect
237
+ v-if="composeServiceOptions.length"
238
+ v-model="store.composeService"
239
+ :items="composeServiceOptions"
240
+ value-key="value"
241
+ :placeholder="t('environmentWizard.review.servicePlaceholder')"
242
+ class="w-full"
243
+ data-testid="env-setup-service-select"
244
+ />
245
+ <UInput
246
+ v-else
247
+ v-model="store.composeService"
248
+ :placeholder="t('environmentWizard.review.servicePlaceholder')"
249
+ class="w-full"
250
+ data-testid="env-setup-service-input"
251
+ />
252
+ </UFormField>
253
+ <UFormField :label="t('environmentWizard.review.port')">
254
+ <UInput
255
+ v-model.number="store.exposedPort"
256
+ type="number"
257
+ class="w-full"
258
+ data-testid="env-setup-port"
259
+ />
260
+ </UFormField>
261
+ </div>
262
+
263
+ <!-- compose file layering -->
264
+ <div v-if="composeFileCandidates.length" class="space-y-1.5">
265
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
266
+ {{ t('environmentWizard.review.composeFiles') }}
267
+ </p>
268
+ <div class="flex flex-wrap gap-1.5">
269
+ <UButton
270
+ v-for="c in composeFileCandidates"
271
+ :key="c.path"
272
+ size="xs"
273
+ :color="fileEnabled(c.path) ? 'primary' : 'neutral'"
274
+ :variant="fileEnabled(c.path) ? 'soft' : 'ghost'"
275
+ :icon="fileEnabled(c.path) ? 'i-lucide-check' : 'i-lucide-plus'"
276
+ :data-testid="`env-setup-file-${c.name}`"
277
+ @click="store.toggleComposeFile(c.path)"
278
+ >
279
+ {{ c.name }}<span v-if="c.os" class="ms-1 text-[9px] opacity-70">{{ c.os }}</span>
280
+ </UButton>
281
+ </div>
282
+ </div>
283
+
284
+ <!-- compose profiles -->
285
+ <div v-if="profileCandidates.length" class="space-y-1.5">
286
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
287
+ {{ t('environmentWizard.review.profiles') }}
288
+ </p>
289
+ <div class="flex flex-wrap gap-1.5">
290
+ <UButton
291
+ v-for="c in profileCandidates"
292
+ :key="c.profile"
293
+ size="xs"
294
+ :color="profileEnabled(c.profile) ? 'primary' : 'neutral'"
295
+ :variant="profileEnabled(c.profile) ? 'soft' : 'ghost'"
296
+ :icon="profileEnabled(c.profile) ? 'i-lucide-check' : 'i-lucide-plus'"
297
+ :data-testid="`env-setup-profile-${c.profile}`"
298
+ @click="store.toggleProfile(c.profile)"
299
+ >
300
+ {{ c.profile }}
301
+ </UButton>
302
+ </div>
303
+ </div>
304
+
305
+ <!-- seed dumps -->
306
+ <div v-if="seedDumpCandidates.length" class="space-y-1.5">
307
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
308
+ {{ t('environmentWizard.review.seedDumps') }}
309
+ </p>
310
+ <div class="space-y-1">
311
+ <div
312
+ v-for="c in seedDumpCandidates"
313
+ :key="c.path"
314
+ class="flex items-center justify-between gap-2 rounded border border-slate-800 bg-slate-900/40 px-2 py-1"
315
+ >
316
+ <span class="truncate text-[11px] text-slate-300">{{ c.path }}</span>
317
+ <UButton
318
+ size="xs"
319
+ :color="seedAdded(c.path) ? 'success' : 'neutral'"
320
+ :variant="seedAdded(c.path) ? 'soft' : 'ghost'"
321
+ :icon="seedAdded(c.path) ? 'i-lucide-check' : 'i-lucide-plus'"
322
+ :disabled="seedAdded(c.path)"
323
+ :data-testid="`env-setup-seed-${c.name}`"
324
+ @click="store.addSeedStep(c)"
325
+ >
326
+ {{
327
+ seedAdded(c.path)
328
+ ? t('environmentWizard.review.seedAdded')
329
+ : t('environmentWizard.review.seedAdd')
330
+ }}
331
+ </UButton>
332
+ </div>
333
+ </div>
334
+ </div>
335
+
336
+ <!-- raw recipe editor -->
337
+ <div>
338
+ <UButton
339
+ size="xs"
340
+ variant="ghost"
341
+ color="neutral"
342
+ :icon="rawOpen ? 'i-lucide-chevron-down' : 'i-lucide-code'"
343
+ data-testid="env-setup-raw-toggle"
344
+ @click="toggleRaw"
345
+ >
346
+ {{ t('environmentWizard.review.rawEditor') }}
347
+ </UButton>
348
+ <div v-if="rawOpen" class="mt-2 space-y-2">
349
+ <UTextarea
350
+ v-model="rawText"
351
+ :rows="10"
352
+ class="w-full font-mono text-[11px]"
353
+ data-testid="env-setup-raw-text"
354
+ />
355
+ <p v-if="rawError" class="text-[11px] text-rose-300/80" data-testid="env-setup-raw-error">
356
+ {{ rawError }}
357
+ </p>
358
+ <div class="flex justify-end">
359
+ <UButton size="xs" color="primary" data-testid="env-setup-raw-apply" @click="applyRaw">
360
+ {{ t('environmentWizard.review.rawApply') }}
361
+ </UButton>
362
+ </div>
363
+ </div>
364
+ </div>
365
+ </template>
366
+
367
+ <JourneyStepNav :go-back="goBack">
368
+ <template #primary>
369
+ <UButton
370
+ color="primary"
371
+ trailing-icon="i-lucide-arrow-right"
372
+ :disabled="!canLeaveReview"
373
+ data-testid="env-setup-next"
374
+ @click="exit('advance')"
375
+ >
376
+ {{ t('common.next') }}
377
+ </UButton>
378
+ </template>
379
+ </JourneyStepNav>
380
+ </section>
381
+ </template>
@@ -0,0 +1,109 @@
1
+ <script setup lang="ts">
2
+ // The environment-setup journey's SAVE step (slice 3 of the modular-vue adoption).
3
+ // Persists the confirmed recipe onto the frame + registers the workspace's
4
+ // docker-compose handler, then optionally trial-provisions with live logs.
5
+ // "Done" fires the journey's `advance` exit, which the transition maps to `complete`
6
+ // — the host closes the modal on finish.
7
+ import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
8
+ import JourneyStepNav from '~/components/environments/steps/JourneyStepNav.vue'
9
+ import { useEnvironmentWizardTarget } from '~/modular/journeys/environmentSetup.frame'
10
+
11
+ const props = defineProps<{
12
+ input: { frameId: string | null }
13
+ exit: (name: 'advance') => void
14
+ goBack?: () => void
15
+ }>()
16
+
17
+ const store = useEnvironmentWizardStore()
18
+ const { t } = useI18n()
19
+
20
+ // Keep the data store pointed at THIS step's frame, so `save()` persists the
21
+ // frame the journey is actually on (not a stale one). Idempotent; see composable.
22
+ useEnvironmentWizardTarget(() => props.input.frameId)
23
+ </script>
24
+
25
+ <template>
26
+ <section class="space-y-3" data-testid="env-setup-step-save">
27
+ <UFormField
28
+ :label="t('environmentWizard.save.handlerLabel')"
29
+ :description="t('environmentWizard.save.handlerHint')"
30
+ >
31
+ <UInput v-model="store.handlerLabel" class="w-full" data-testid="env-setup-handler-label" />
32
+ </UFormField>
33
+
34
+ <div class="rounded-md border border-slate-800 bg-slate-900/40 p-3 text-[12px] text-slate-300">
35
+ <p>
36
+ {{
37
+ t('environmentWizard.save.summary', {
38
+ frame: store.targetFrame?.title ?? '',
39
+ service: store.composeService,
40
+ })
41
+ }}
42
+ </p>
43
+ </div>
44
+
45
+ <p
46
+ v-if="store.saveError"
47
+ class="text-[12px] text-rose-300/80"
48
+ data-testid="env-setup-save-error"
49
+ >
50
+ {{ store.saveError }}
51
+ </p>
52
+
53
+ <div v-if="!store.saved" class="flex justify-end">
54
+ <UButton
55
+ color="primary"
56
+ icon="i-lucide-save"
57
+ :loading="store.saving"
58
+ :disabled="!store.composeService.trim()"
59
+ data-testid="env-setup-save"
60
+ @click="store.save()"
61
+ >
62
+ {{ t('environmentWizard.save.save') }}
63
+ </UButton>
64
+ </div>
65
+
66
+ <!-- saved: confirmation + optional trial provision -->
67
+ <template v-else>
68
+ <div
69
+ class="flex items-center gap-2 rounded-md border border-emerald-800/50 bg-emerald-950/30 p-2 text-[12px] text-emerald-200"
70
+ data-testid="env-setup-saved"
71
+ >
72
+ <UIcon name="i-lucide-check-circle" class="h-4 w-4" />
73
+ {{ t('environmentWizard.save.saved') }}
74
+ </div>
75
+
76
+ <div class="flex items-center justify-between gap-2">
77
+ <p class="text-[11px] text-slate-500">{{ t('environmentWizard.trial.hint') }}</p>
78
+ <UButton
79
+ size="xs"
80
+ variant="soft"
81
+ color="neutral"
82
+ icon="i-lucide-play"
83
+ :loading="store.trialing"
84
+ :disabled="store.trialStarted"
85
+ data-testid="env-setup-trial"
86
+ @click="store.trialProvision()"
87
+ >
88
+ {{ t('environmentWizard.trial.run') }}
89
+ </UButton>
90
+ </div>
91
+ <p v-if="store.trialError" class="text-[11px] text-rose-300/80">{{ store.trialError }}</p>
92
+ <ProvisioningLogsDrawer v-if="store.trialStarted" subsystem="environment" />
93
+ </template>
94
+
95
+ <JourneyStepNav :go-back="goBack">
96
+ <template #primary>
97
+ <UButton
98
+ color="neutral"
99
+ variant="soft"
100
+ icon="i-lucide-check"
101
+ data-testid="env-setup-done"
102
+ @click="exit('advance')"
103
+ >
104
+ {{ t('common.done') }}
105
+ </UButton>
106
+ </template>
107
+ </JourneyStepNav>
108
+ </section>
109
+ </template>
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ // Shared footer chrome for a modular-vue journey step (slice 3 of the modular-vue
3
+ // adoption — docs/initiatives/modular-vue-adoption.md). Renders the Back control
4
+ // (wired to the host-provided `goBack`, present only when the current entry
5
+ // declared `allowBack` and there's a prior step) plus a `primary` slot for the
6
+ // step's own advance affordance, so gating stays local to each step.
7
+ const props = defineProps<{
8
+ /** The host-provided rewind callback (`ModuleEntryProps.goBack`), or undefined
9
+ * on the first step / when back isn't allowed. */
10
+ goBack?: () => void
11
+ }>()
12
+
13
+ const { t } = useI18n()
14
+ </script>
15
+
16
+ <template>
17
+ <div class="flex items-center justify-between border-t border-slate-800 pt-3">
18
+ <UButton
19
+ color="neutral"
20
+ variant="ghost"
21
+ :disabled="!props.goBack"
22
+ icon="i-lucide-arrow-left"
23
+ data-testid="env-setup-back"
24
+ @click="props.goBack?.()"
25
+ >
26
+ {{ t('common.back') }}
27
+ </UButton>
28
+ <slot name="primary" />
29
+ </div>
30
+ </template>