@cat-factory/app 0.152.0 → 0.152.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,68 @@
1
+ import type { ComputedRef, Ref } from 'vue'
2
+ import type {
3
+ MergedRecipeDraft,
4
+ PreflightResult,
5
+ ProvisioningRecommendation,
6
+ StackRecipe,
7
+ } from '@cat-factory/contracts'
8
+ import type { useBoardStore } from '~/stores/board'
9
+ import type { useExecutionStore } from '~/stores/execution'
10
+ import type { useGitHubStore } from '~/stores/github'
11
+ import type { useInfraConfigStore } from '~/stores/infraConfig'
12
+ import type { usePipelinesStore } from '~/stores/pipelines'
13
+ import type { usePreflightsStore } from '~/stores/preflights'
14
+
15
+ /**
16
+ * Shared reactive state + resolved store handles the environment-wizard action factories
17
+ * ({@link import('./flow').createFlowActions}, {@link import('./recipe').createRecipeActions},
18
+ * {@link import('./save').createSaveActions}) close over. Assembled once in the store setup and
19
+ * threaded into each factory so the split actions stay behaviourally identical to the former
20
+ * single-closure store — a size-only extraction following the `board` store idiom.
21
+ */
22
+ export interface WizardContext {
23
+ board: ReturnType<typeof useBoardStore>
24
+ github: ReturnType<typeof useGitHubStore>
25
+ infra: ReturnType<typeof useInfraConfigStore>
26
+ execution: ReturnType<typeof useExecutionStore>
27
+ preflights: ReturnType<typeof usePreflightsStore>
28
+ // ---- state ----
29
+ frameId: Ref<string | null>
30
+ detecting: Ref<boolean>
31
+ detectError: Ref<boolean>
32
+ recommendation: Ref<ProvisioningRecommendation | null>
33
+ analysisRequested: Ref<boolean>
34
+ analysisError: Ref<boolean>
35
+ recipe: Ref<StackRecipe>
36
+ composeService: Ref<string>
37
+ preflightRunning: Ref<boolean>
38
+ preflightResults: Ref<PreflightResult[] | null>
39
+ preflightError: Ref<string | null>
40
+ handlerLabel: Ref<string>
41
+ exposedPort: Ref<number>
42
+ saving: Ref<boolean>
43
+ saveError: Ref<string | null>
44
+ saved: Ref<boolean>
45
+ trialing: Ref<boolean>
46
+ trialError: Ref<string | null>
47
+ trialStarted: Ref<boolean>
48
+ // ---- derived the actions read ----
49
+ repoContext: ComputedRef<{ githubId: number; directory?: string | null } | undefined>
50
+ analysisPipeline: ComputedRef<ReturnType<ReturnType<typeof usePipelinesStore>['getPipeline']>>
51
+ merged: ComputedRef<MergedRecipeDraft | null>
52
+ }
53
+
54
+ /** Drop empty arrays / undefined so the persisted recipe stays minimal and schema-valid
55
+ * (`composeFiles` etc. are `minLength(1)`, so an empty array would 422). */
56
+ export function pruneRecipe(recipe: StackRecipe): StackRecipe {
57
+ const out: Record<string, unknown> = {}
58
+ for (const [key, value] of Object.entries(recipe)) {
59
+ if (value === undefined || value === null) continue
60
+ if (Array.isArray(value) && value.length === 0) continue
61
+ out[key] = value
62
+ }
63
+ return out as StackRecipe
64
+ }
65
+
66
+ export function cloneRecipe(recipe: StackRecipe): StackRecipe {
67
+ return JSON.parse(JSON.stringify(recipe)) as StackRecipe
68
+ }
@@ -0,0 +1,143 @@
1
+ import type { WizardContext } from './context'
2
+ import { cloneRecipe } from './context'
3
+
4
+ /**
5
+ * The wizard's per-frame lifecycle + recommendation actions: reset/target a frame, (re)seed the
6
+ * working recipe from the merged recommendation, run checkout-free detection, fire the analyst
7
+ * pipeline, and fold a ready analyst draft in. Closes over the shared {@link WizardContext};
8
+ * behaviour is identical to the former in-closure functions (a size-only extraction). The internal
9
+ * `resetFlowState` / `seedFromMerged` helpers are not exposed (they were never part of the public
10
+ * store shape).
11
+ */
12
+ export function createFlowActions(ctx: WizardContext) {
13
+ const {
14
+ github,
15
+ infra,
16
+ execution,
17
+ frameId,
18
+ detecting,
19
+ detectError,
20
+ recommendation,
21
+ analysisRequested,
22
+ analysisError,
23
+ recipe,
24
+ composeService,
25
+ preflightRunning,
26
+ preflightResults,
27
+ preflightError,
28
+ handlerLabel,
29
+ exposedPort,
30
+ saving,
31
+ saveError,
32
+ saved,
33
+ trialing,
34
+ trialError,
35
+ trialStarted,
36
+ repoContext,
37
+ analysisPipeline,
38
+ merged,
39
+ } = ctx
40
+
41
+ /**
42
+ * Clear all per-frame flow state (detection, working recipe, preflight, save, trial). Shared by
43
+ * `beginForFrame` so re-targeting the wizard at a different frame can't leave a prior frame's
44
+ * `saved`/`composeService`/`exposedPort`/results behind (which would make an unsaved frame render
45
+ * the green "saved" confirmation + offer a trial provision).
46
+ */
47
+ function resetFlowState() {
48
+ detecting.value = false
49
+ detectError.value = false
50
+ recommendation.value = null
51
+ analysisRequested.value = false
52
+ analysisError.value = false
53
+ recipe.value = {}
54
+ composeService.value = ''
55
+ preflightRunning.value = false
56
+ preflightResults.value = null
57
+ preflightError.value = null
58
+ handlerLabel.value = 'Docker Compose'
59
+ exposedPort.value = 80
60
+ saving.value = false
61
+ saveError.value = null
62
+ saved.value = false
63
+ trialing.value = false
64
+ trialError.value = null
65
+ trialStarted.value = false
66
+ }
67
+
68
+ /** Re-seed the working recipe from the current merge (detector-only, or +analyst after apply). */
69
+ function seedFromMerged() {
70
+ if (merged.value) recipe.value = cloneRecipe(merged.value.recipe)
71
+ // Default the exposed service to the detector's recommended compose service, when known.
72
+ const recommended = recommendation.value?.composeServiceCandidates?.find((c) => c.recommended)
73
+ if (recommended && !composeService.value) composeService.value = recommended.service
74
+ }
75
+
76
+ /** Run checkout-free detection for the frame's repo (non-binding; seeds the working recipe). */
77
+ async function detect() {
78
+ const target = repoContext.value
79
+ if (!target) {
80
+ detectError.value = true
81
+ return
82
+ }
83
+ const repo = github.repoFor(target.githubId)
84
+ if (!repo) {
85
+ detectError.value = true
86
+ return
87
+ }
88
+ detecting.value = true
89
+ detectError.value = false
90
+ try {
91
+ const rec = await infra.detectProvisioning({
92
+ owner: repo.owner,
93
+ repo: repo.name,
94
+ ...(target.directory ? { directory: target.directory } : {}),
95
+ prefer: 'docker-compose',
96
+ })
97
+ recommendation.value = rec
98
+ // Seed the exposed port + build flag from the detected provisioning where present.
99
+ seedFromMerged()
100
+ } catch {
101
+ detectError.value = true
102
+ } finally {
103
+ detecting.value = false
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Seed the data layer for a frame the journey's review step is entering. The journey owns
109
+ * navigation, so this is idempotent by frame: it (re)seeds + detects only when the target frame
110
+ * actually changes, so back-navigating to the review step (or a resume) does NOT clobber the
111
+ * operator's in-progress recipe edits. Selecting a different frame resets the flow for it.
112
+ */
113
+ function beginForFrame(id: string | null) {
114
+ if (frameId.value === id) return
115
+ frameId.value = id
116
+ resetFlowState()
117
+ if (id) void detect()
118
+ }
119
+
120
+ /** Fire the analyst-only pipeline against the frame (mirrors how bootstrap runs pl_blueprint). */
121
+ async function startAnalysis() {
122
+ const id = frameId.value
123
+ const pipeline = analysisPipeline.value
124
+ if (!id || !pipeline) {
125
+ analysisError.value = true
126
+ return
127
+ }
128
+ analysisError.value = false
129
+ try {
130
+ await execution.start(id, pipeline)
131
+ analysisRequested.value = true
132
+ } catch {
133
+ analysisError.value = true
134
+ }
135
+ }
136
+
137
+ /** Fold the (now-ready) analyst draft into the working recipe (re-seed from the merge). */
138
+ function applyAnalystDraft() {
139
+ seedFromMerged()
140
+ }
141
+
142
+ return { beginForFrame, detect, startAnalysis, applyAnalystDraft }
143
+ }
@@ -0,0 +1,63 @@
1
+ import * as v from 'valibot'
2
+ import { type ProvisioningSeedDumpCandidate, stackRecipeSchema } from '@cat-factory/contracts'
3
+ import type { WizardContext } from './context'
4
+
5
+ /**
6
+ * The working-recipe editing actions (compose-file / profile toggles, seed-step insertion, raw-JSON
7
+ * replace). Closes over the shared {@link WizardContext}; behaviour is identical to the former
8
+ * in-closure functions (a size-only extraction).
9
+ */
10
+ export function createRecipeActions(ctx: WizardContext) {
11
+ const { recipe, composeService } = ctx
12
+
13
+ /** Toggle an OS-override / extra compose file into the working recipe's ordered `composeFiles`. */
14
+ function toggleComposeFile(path: string) {
15
+ const files = recipe.value.composeFiles ? [...recipe.value.composeFiles] : []
16
+ const idx = files.indexOf(path)
17
+ if (idx >= 0) files.splice(idx, 1)
18
+ else files.push(path)
19
+ recipe.value = { ...recipe.value, composeFiles: files }
20
+ }
21
+
22
+ /** Toggle a `COMPOSE_PROFILES` label into the working recipe. */
23
+ function toggleProfile(profile: string) {
24
+ const profiles = recipe.value.composeProfiles ? [...recipe.value.composeProfiles] : []
25
+ const idx = profiles.indexOf(profile)
26
+ if (idx >= 0) profiles.splice(idx, 1)
27
+ else profiles.push(profile)
28
+ recipe.value = { ...recipe.value, composeProfiles: profiles }
29
+ }
30
+
31
+ /**
32
+ * Convert a confirmed seed-dump candidate into a `compose-exec` step that pipes the dump via
33
+ * stdin. The service + command are a best-effort default (the exposed/db service + a `cat`
34
+ * placeholder) the operator refines in the recipe editor — detection can't know the DB client.
35
+ */
36
+ function addSeedStep(candidate: ProvisioningSeedDumpCandidate) {
37
+ const setupSteps = recipe.value.setupSteps ? [...recipe.value.setupSteps] : []
38
+ setupSteps.push({
39
+ kind: 'compose-exec',
40
+ name: `Import seed ${candidate.name}`,
41
+ service: composeService.value || 'db',
42
+ command: ['sh', '-c', 'cat'],
43
+ stdinFile: candidate.path,
44
+ })
45
+ recipe.value = { ...recipe.value, setupSteps }
46
+ }
47
+
48
+ /** Replace the working recipe from a raw-JSON edit; returns an error message or null on success. */
49
+ function setRecipeFromJson(text: string): string | null {
50
+ let parsedJson: unknown
51
+ try {
52
+ parsedJson = JSON.parse(text)
53
+ } catch (err) {
54
+ return err instanceof Error ? err.message : 'Invalid JSON'
55
+ }
56
+ const result = v.safeParse(stackRecipeSchema, parsedJson)
57
+ if (!result.success) return result.issues.map((i) => i.message).join('; ')
58
+ recipe.value = result.output
59
+ return null
60
+ }
61
+
62
+ return { toggleComposeFile, toggleProfile, addSeedStep, setRecipeFromJson }
63
+ }
@@ -0,0 +1,132 @@
1
+ import { apiErrorEnvelope } from '~/composables/api/errors'
2
+ import { useWorkspaceStore } from '~/stores/workspace'
3
+ import type { WizardContext } from './context'
4
+ import { pruneRecipe } from './context'
5
+
6
+ /**
7
+ * The preflight / persist / trial-provision actions — the tail of the wizard. Closes over the
8
+ * shared {@link WizardContext}; behaviour is identical to the former in-closure functions (a
9
+ * size-only extraction).
10
+ */
11
+ export function createSaveActions(ctx: WizardContext) {
12
+ const {
13
+ board,
14
+ infra,
15
+ preflights,
16
+ frameId,
17
+ recommendation,
18
+ recipe,
19
+ composeService,
20
+ preflightRunning,
21
+ preflightResults,
22
+ preflightError,
23
+ handlerLabel,
24
+ exposedPort,
25
+ saving,
26
+ saveError,
27
+ saved,
28
+ trialing,
29
+ trialError,
30
+ trialStarted,
31
+ } = ctx
32
+
33
+ /** Run the working recipe's declared preflight checks (host-bound; degrades on a non-local facade). */
34
+ async function runPreflight() {
35
+ preflightRunning.value = true
36
+ preflightError.value = null
37
+ try {
38
+ preflightResults.value = await preflights.run(recipe.value.prerequisites ?? [])
39
+ } catch (err) {
40
+ // A 503 is handled inside `preflights.run` (degraded note); anything else is a real failure
41
+ // that must be shown rather than swallowed into an unhandled rejection.
42
+ preflightError.value =
43
+ apiErrorEnvelope(err)?.message ?? (err instanceof Error ? err.message : String(err))
44
+ } finally {
45
+ preflightRunning.value = false
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Persist the confirmed config: register the workspace's `docker-compose` handler (so the Deployer
51
+ * can provision it) AND write the recipe onto the service frame's provisioning. The handler carries
52
+ * only the daemon "how" (the exposed service + port); the recipe is the per-service "what/where".
53
+ */
54
+ async function save() {
55
+ const id = frameId.value
56
+ const service = composeService.value.trim()
57
+ if (!id || !service) {
58
+ saveError.value = 'A frame and an exposed compose service are required.'
59
+ return
60
+ }
61
+ // `exposedPort` is a `v-model.number` field, which yields '' (not a number) when cleared. Guard
62
+ // here so an empty/out-of-range port can't reach the handler manifest.
63
+ const port = Number(exposedPort.value)
64
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
65
+ saveError.value = 'Enter a valid exposed port (1-65535).'
66
+ return
67
+ }
68
+ saving.value = true
69
+ saveError.value = null
70
+ const pruned = pruneRecipe(recipe.value)
71
+ const build = recommendation.value?.provisioning.composeBuild === true
72
+ const allowHostCommands = (pruned.setupSteps ?? []).some((s) => s.kind === 'host-command')
73
+ try {
74
+ await infra.registerHandler({
75
+ provisionType: 'docker-compose',
76
+ config: {
77
+ engine: 'local-docker',
78
+ manifest: {
79
+ providerId: 'compose',
80
+ label: handlerLabel.value.trim() || 'Docker Compose',
81
+ baseUrl: 'http://localhost',
82
+ auth: { type: 'none' },
83
+ provision: { method: 'POST', pathTemplate: '' },
84
+ response: {},
85
+ providerConfig: {
86
+ service,
87
+ port,
88
+ ...(build ? { build: true } : {}),
89
+ ...(allowHostCommands ? { allowHostCommands: true } : {}),
90
+ },
91
+ },
92
+ },
93
+ secrets: {},
94
+ })
95
+ await board.updateBlock(id, {
96
+ provisioning: {
97
+ type: 'docker-compose',
98
+ ...(pruned.composeFiles?.[0] ? { composePath: pruned.composeFiles[0] } : {}),
99
+ ...(build ? { composeBuild: true } : {}),
100
+ recipe: pruned,
101
+ },
102
+ })
103
+ saved.value = true
104
+ } catch (err) {
105
+ saveError.value =
106
+ apiErrorEnvelope(err)?.message ?? (err instanceof Error ? err.message : String(err))
107
+ } finally {
108
+ saving.value = false
109
+ }
110
+ }
111
+
112
+ /** Optional trial: provision the just-saved config for the frame (local-only; live logs shown). */
113
+ async function trialProvision() {
114
+ const id = frameId.value
115
+ if (!id || !saved.value) return
116
+ trialing.value = true
117
+ trialError.value = null
118
+ try {
119
+ const api = useApi()
120
+ const ws = useWorkspaceStore()
121
+ await api.provisionEnvironment(ws.requireId(), { blockId: id })
122
+ trialStarted.value = true
123
+ } catch (err) {
124
+ trialError.value =
125
+ apiErrorEnvelope(err)?.message ?? (err instanceof Error ? err.message : String(err))
126
+ } finally {
127
+ trialing.value = false
128
+ }
129
+ }
130
+
131
+ return { runPreflight, save, trialProvision }
132
+ }