@stacksjs/defaults 0.70.366 → 0.70.368
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ai/skills/stacks-browse/SKILL.md +9 -1
- package/ai/skills/stacks-dashboard/SKILL.md +49 -1
- package/ai/skills/stacks-deploy/SKILL.md +18 -0
- package/ai/skills/stacks-desktop/SKILL.md +11 -2
- package/ai/skills/stacks-stx/SKILL.md +25 -0
- package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +64 -44
- package/app/Actions/Dashboard/Deployments/CreateDeploymentRollback.ts +26 -0
- package/app/Actions/Dashboard/Deployments/PreviewDeployment.ts +39 -0
- package/app/Actions/Dashboard/Deployments/PreviewDeploymentRollback.ts +22 -0
- package/app/Actions/Dashboard/Deployments/deployment-input.test.ts +12 -2
- package/app/Actions/Dashboard/Deployments/deployment-input.ts +5 -3
- package/app/Actions/Dashboard/Deployments/deployment-preview.test.ts +29 -0
- package/app/Actions/Dashboard/Deployments/deployment-preview.ts +41 -0
- package/app/Actions/Dashboard/Deployments/deployment-rollback.test.ts +16 -0
- package/app/Actions/Dashboard/Deployments/deployment-rollback.ts +89 -0
- package/app/Actions/Dashboard/Operations/AuditIndexAction.ts +25 -0
- package/app/Actions/Dashboard/Operations/ChangeApprovalAction.ts +28 -0
- package/app/Actions/Dashboard/Operations/ChangeIndexAction.ts +32 -0
- package/app/Actions/Dashboard/Operations/IncidentIndexAction.ts +32 -0
- package/app/Actions/Dashboard/Operations/IncidentUpdateAction.ts +34 -0
- package/app/Actions/Dashboard/Operations/MigrationApplyAction.ts +30 -0
- package/app/Actions/Dashboard/Operations/MigrationIndexAction.ts +24 -0
- package/app/Actions/Dashboard/Operations/MigrationReconcileAction.ts +23 -0
- package/app/Actions/Dashboard/Operations/RecoveryDestinationStoreAction.ts +91 -0
- package/app/Actions/Dashboard/Operations/RecoveryDestinationTestAction.ts +34 -0
- package/app/Actions/Dashboard/Operations/RecoveryIndexAction.ts +58 -0
- package/app/Actions/Dashboard/Operations/RecoveryPolicyStoreAction.ts +90 -0
- package/app/Actions/Dashboard/Operations/RecoveryProtectAction.ts +32 -0
- package/app/Actions/Dashboard/Operations/RecoveryRestoreAction.ts +72 -0
- package/app/Actions/Dashboard/Operations/RecoveryRetentionAction.ts +17 -0
- package/app/Actions/Dashboard/Operations/RecoveryRunAction.ts +25 -0
- package/app/Actions/Dashboard/Operations/RecoveryVerifyAction.ts +24 -0
- package/app/Actions/Dashboard/Operations/SchedulerIndexAction.ts +34 -0
- package/app/Actions/Dashboard/Operations/SchedulerRunAction.ts +33 -0
- package/app/Actions/Dashboard/Operations/SchedulerToggleAction.ts +36 -0
- package/app/Actions/Dashboard/Operations/control-plane.ts +150 -0
- package/app/Actions/Dashboard/Operations/migration-operations.ts +80 -0
- package/app/Actions/Dashboard/Operations/operations-runtime.ts +11 -0
- package/app/Actions/Dashboard/Operations/recovery-input.test.ts +22 -0
- package/app/Actions/Dashboard/Operations/recovery-input.ts +33 -0
- package/app/Actions/Dashboard/Operations/recovery-runtime.ts +99 -0
- package/app/Actions/Dashboard/Operations/scheduler-operations.test.ts +23 -0
- package/app/Actions/Dashboard/Operations/scheduler-operations.ts +138 -0
- package/functions/deployments.ts +18 -0
- package/functions/operations.ts +361 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +1 -1
- package/resources/components/Dashboard/Ci/CiDashboard.stx +4 -4
- package/resources/components/Dashboard/Deployments/DeploymentList.stx +147 -14
- package/resources/components/Dashboard/Deployments/DeploymentPreviewDialog.stx +118 -0
- package/resources/components/Dashboard/GlobalSearch.stx +87 -91
- package/resources/components/Dashboard/Operations/AuditDashboard.stx +16 -0
- package/resources/components/Dashboard/Operations/ChangeDashboard.stx +66 -0
- package/resources/components/Dashboard/Operations/IncidentDashboard.stx +39 -0
- package/resources/components/Dashboard/Operations/MigrationDashboard.stx +146 -0
- package/resources/components/Dashboard/Operations/OperationHistory.stx +61 -0
- package/resources/components/Dashboard/Operations/OperationsNavigation.stx +32 -0
- package/resources/components/Dashboard/Operations/RecoveryDashboard.stx +229 -0
- package/resources/components/Dashboard/Operations/RecoveryDestinationDialog.stx +126 -0
- package/resources/components/Dashboard/Operations/RecoveryJobList.stx +32 -0
- package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +82 -0
- package/resources/components/Dashboard/Operations/RecoveryPolicyDialog.stx +155 -0
- package/resources/components/Dashboard/Operations/RecoveryRestoreDialog.stx +90 -0
- package/resources/components/Dashboard/Operations/SchedulerDashboard.stx +144 -0
- package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +81 -0
- package/resources/components/Dashboard/Settings/SettingsDashboard.stx +45 -3
- package/resources/components/Dashboard/UI/Modal.stx +33 -8
- package/resources/functions/dashboard/sidebar.ts +1 -0
- package/routes/dashboard-api.ts +23 -0
- package/routes/dashboard.ts +1 -0
- package/vcs/github/workflows/ci.yml +3 -3
- package/vcs/github/workflows/release.yml +1 -1
- package/views/dashboard/operations/audit.stx +5 -0
- package/views/dashboard/operations/changes.stx +5 -0
- package/views/dashboard/operations/incidents.stx +5 -0
- package/views/dashboard/operations/migrations.stx +8 -0
- package/views/dashboard/operations/recovery.stx +8 -0
- package/views/dashboard/operations/scheduler.stx +8 -0
- package/views/dashboard/stores/ci.ts +2 -2
|
@@ -45,6 +45,11 @@ you to `brew install --cask chromium` or set `BROWSE_BROWSER`.
|
|
|
45
45
|
- Docs: `http://localhost:3005`
|
|
46
46
|
- API: `http://localhost:3008`
|
|
47
47
|
|
|
48
|
+
For headless dashboard QA, start the server with
|
|
49
|
+
`STACKS_NO_NATIVE=1 ./buddy dev --dashboard`. Never point `CRAFT_BIN` at a
|
|
50
|
+
missing file to suppress the native window because invalid binary overrides are
|
|
51
|
+
configuration errors.
|
|
52
|
+
|
|
48
53
|
> Pages with an open HMR/SSE connection never reach "network idle"; the driver waits for
|
|
49
54
|
> the load event (with a timeout) plus a short settle, so it won't hang on the dev server.
|
|
50
55
|
|
|
@@ -133,7 +138,10 @@ diagnostics without printing every visited path or overflowing element.
|
|
|
133
138
|
|
|
134
139
|
When testing a Stacks app, check:
|
|
135
140
|
- **Dashboard routes** — admin pages rendering? (`localhost:3002`)
|
|
136
|
-
- **API health** — `GET localhost:3008/health` returns ok?
|
|
141
|
+
- **API health** — `GET localhost:3008/api/health` returns ok? (`/api/health`, not
|
|
142
|
+
`/health`: the probe is mounted under `/api` so it cannot collide with a page of
|
|
143
|
+
the same name, and the API port answers `/api` only - every other path is a 404
|
|
144
|
+
there by design, so point page checks at the frontend instead.)
|
|
137
145
|
- **Auth flow** — `/login`, `/register`
|
|
138
146
|
- **CMS/blog** — `/blog`, post detail pages, `/blog/feed.xml`, `/blog/sitemap.xml`
|
|
139
147
|
- **STX components** — do custom components render server-side?
|
|
@@ -111,12 +111,60 @@ inject message HTML into the dashboard document.
|
|
|
111
111
|
- `/deployments/{id}` - one persisted Deployment model record
|
|
112
112
|
|
|
113
113
|
The deployment page composes `DeploymentList`, `DeploymentTable`,
|
|
114
|
-
`DeployScript`, and `LiveTerminalOutput`.
|
|
114
|
+
`DeploymentPreviewDialog`, `DeployScript`, and `LiveTerminalOutput`. The
|
|
115
|
+
Preview action first collects the environment and optional domain, then calls
|
|
116
|
+
the guarded `POST /api/dashboard/deployments/preview` Action. That Action runs
|
|
117
|
+
the native `buddy deploy --dry-run --json` planner and returns its versioned,
|
|
118
|
+
non-mutating plan. The dialog renders the ordered operations and resolved sites
|
|
119
|
+
before the user may continue to the separate real deployment confirmation.
|
|
120
|
+
Script reads and atomic writes use
|
|
115
121
|
`GET|PUT /api/dashboard/deployments/script`. The terminal uses
|
|
116
122
|
`GET /api/dashboard/deployments/terminal` and pauses polling while the document
|
|
117
123
|
is hidden. Do not create separate `/deployments/scripts` or
|
|
118
124
|
`/deployments/live-terminal` pages.
|
|
119
125
|
|
|
126
|
+
Deployment recovery uses `POST /api/dashboard/deployments/rollback/preview`
|
|
127
|
+
followed by `POST /api/dashboard/deployments/rollback`. The preview must run
|
|
128
|
+
the native `buddy deploy:rollback --dry-run` path successfully, and execution
|
|
129
|
+
must re-run that preview, compare its revision, and require the typed target
|
|
130
|
+
environment confirmation. Never implement rollback by editing release links,
|
|
131
|
+
restarting services directly, or guessing a prior release from Deployment
|
|
132
|
+
model rows. Deployment rows are application history. Preserved releases and
|
|
133
|
+
activation are owned by ts-cloud.
|
|
134
|
+
|
|
135
|
+
### Operations control plane
|
|
136
|
+
|
|
137
|
+
The Operations sidebar entry deliberately remains one item. Section navigation
|
|
138
|
+
lives in `Dashboard/Operations/OperationsNavigation.stx`:
|
|
139
|
+
|
|
140
|
+
- `/operations/changes` - unified change review, active work, release approvals
|
|
141
|
+
- `/operations/scheduler` - registered task runs and persisted pause state
|
|
142
|
+
- `/operations/recovery` - destinations, policies, recovery points, restore drills
|
|
143
|
+
- `/operations/migrations` - model diff, schema effects, ledger reconciliation
|
|
144
|
+
- `/operations/incidents` - native alerts, health rules, ownership, silence state
|
|
145
|
+
- `/operations/audit` - append-only operator events and correlations
|
|
146
|
+
|
|
147
|
+
Operational state belongs in the ts-cloud control plane initialized by
|
|
148
|
+
`Operations/control-plane.ts`. Use its stores for durable operations, events,
|
|
149
|
+
releases, approvals, alerts, backups, actors, and environments. Do not create a
|
|
150
|
+
parallel dashboard-only JSON file or duplicate those entities in application
|
|
151
|
+
models. Application domain records still follow the normal `app/Models` and
|
|
152
|
+
`useApi` convention. Use dashboard Actions for aggregate operational views and
|
|
153
|
+
guard every route in `dashboard-api.ts`.
|
|
154
|
+
|
|
155
|
+
Every mutating operator action must resolve the authenticated actor and append
|
|
156
|
+
or correlate a control-plane event. Use `trackOperatorOperation()` for bounded
|
|
157
|
+
synchronous work and the native durable queue for long-running backup, restore,
|
|
158
|
+
or provider work. Empty states must reflect real absence of configuration or
|
|
159
|
+
events. Never seed operational pages with sample incidents, releases, backups,
|
|
160
|
+
or health data.
|
|
161
|
+
|
|
162
|
+
Migration execution must start from `previewPendingMigrations()`, audit the
|
|
163
|
+
ledger against live schema effects, hash the reviewed plan, and recheck it
|
|
164
|
+
immediately before `buddy migrate`. Only `reconcileMigrationLedger()` may
|
|
165
|
+
repair provable ledger drift. Partial and unverifiable migrations require human
|
|
166
|
+
review and must not be silently recorded.
|
|
167
|
+
|
|
120
168
|
### Utilities
|
|
121
169
|
- `/health`, `/insights`, `/logs` - operational health and logs
|
|
122
170
|
- `/servers`, `/serverless`, `/realtime` - runtime infrastructure
|
|
@@ -16,6 +16,23 @@ The deployment workflow for Stacks applications.
|
|
|
16
16
|
buddy deploy
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
Preview the complete environment-aware plan before changing anything:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
buddy deploy --dry-run
|
|
23
|
+
buddy deploy --dry-run --env staging
|
|
24
|
+
buddy deploy --dry-run --site docs
|
|
25
|
+
buddy deploy --dry-run --json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The preview uses the same environment transformation and ts-cloud site
|
|
29
|
+
classification as a real deploy. It reports the ordered validation,
|
|
30
|
+
infrastructure, build, package, release, runtime, gateway, DNS, TLS, and
|
|
31
|
+
optional container operations. It exits before prerequisite setup, builds,
|
|
32
|
+
packaging, hooks, provider calls, persistence, DNS, TLS, or service restarts.
|
|
33
|
+
The JSON form emits a versioned `STACKS_DEPLOY_PREVIEW_JSON=` line for API and
|
|
34
|
+
tool consumers.
|
|
35
|
+
|
|
19
36
|
## Deployment Prerequisites
|
|
20
37
|
|
|
21
38
|
1. **AWS credentials configured**: `buddy configure:aws`
|
|
@@ -87,6 +104,7 @@ buddy deploy
|
|
|
87
104
|
|
|
88
105
|
```bash
|
|
89
106
|
buddy deploy # deploy to cloud
|
|
107
|
+
buddy deploy --dry-run # preview without changing anything
|
|
90
108
|
buddy cloud --diff # preview infrastructure changes before deploying
|
|
91
109
|
buddy cloud --ssh # SSH into deployed server
|
|
92
110
|
buddy cloud:remove # tear down infrastructure
|
|
@@ -23,8 +23,17 @@ Craft window. `buddy build:desktop` compiles a small Stacks launcher and places
|
|
|
23
23
|
the launcher, pinned Craft runtime, manifest, provenance, and checksums in
|
|
24
24
|
`storage/framework/desktop-dist`.
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
Craft is normally installed through pantry and resolved from PATH. Set
|
|
27
|
+
`CRAFT_BIN` to an explicit compiled native binary in CI or local Craft
|
|
28
|
+
development. When testing local SDK source as well, set `CRAFT_SDK_SRC` to its
|
|
29
|
+
`src/index.ts` entry. Set `STACKS_NO_NATIVE=1` only when a deliberately web-only
|
|
30
|
+
dashboard process is required, such as headless browser QA. A missing
|
|
31
|
+
`CRAFT_BIN` is an error and is never treated as a headless flag.
|
|
32
|
+
|
|
33
|
+
Native development windows load the dashboard through its loopback HTTP
|
|
34
|
+
origin. Keep pretty HTTPS domains for browser access. WKWebView does not assume
|
|
35
|
+
that a developer's local certificate authority is trusted, and a failed TLS
|
|
36
|
+
navigation otherwise appears as a blank native window.
|
|
28
37
|
|
|
29
38
|
## API
|
|
30
39
|
|
|
@@ -91,6 +91,30 @@ STX signals are callable. Read with `count()`, replace with
|
|
|
91
91
|
`count.update(value => value + 1)`. Do not use Vue-style `.value` access in
|
|
92
92
|
STX templates or `resources/functions`.
|
|
93
93
|
|
|
94
|
+
### Client loop conditionals
|
|
95
|
+
|
|
96
|
+
Attribute loops create a client-owned scope. Their item and index aliases may
|
|
97
|
+
be used with either reactive attribute conditionals or STX block conditionals.
|
|
98
|
+
The compiler promotes a block chain that reads those aliases into one reactive
|
|
99
|
+
runtime chain:
|
|
100
|
+
|
|
101
|
+
```html
|
|
102
|
+
<template :for="(field, index) in fields()">
|
|
103
|
+
@if(field.editable)
|
|
104
|
+
<input x-model="drafts[field.key]">
|
|
105
|
+
@elseif(index === 0)
|
|
106
|
+
<span>First read-only field</span>
|
|
107
|
+
@else
|
|
108
|
+
<span>{{ field.value }}</span>
|
|
109
|
+
@endif
|
|
110
|
+
</template>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Loop aliases are scoped to the owning element and inherited by nested client
|
|
114
|
+
loops. A condition outside that element remains server-owned. Do not mix a
|
|
115
|
+
server context value into a client loop chain. Serialize the value into client
|
|
116
|
+
state when the browser must reevaluate it.
|
|
117
|
+
|
|
94
118
|
### Native form binding
|
|
95
119
|
|
|
96
120
|
Use `x-model` for two-way form state instead of pairing `:value` with a
|
|
@@ -260,6 +284,7 @@ await addLayout('admin', { nav: true, footer: true })
|
|
|
260
284
|
- **Server-to-client values are explicit** - a JSON-serializable top-level value exported by `<script server>` can be referenced by name in `<script client>`. STX serializes only referenced values and never overwrites a client-owned declaration
|
|
261
285
|
- **`storage/framework/stx/`** — stx build cache and the generated route manifest. `config/ui.ts` sets stx's `stateDir` here, so nothing lands in the project root. Gitignored; safe to delete
|
|
262
286
|
- **Reactivity is signal-based** - use callable `state()` and `derived()` signals, not Vue-style `ref()` or `.value`
|
|
287
|
+
- **Client loop conditions** - `@if` chains that read `:for` or `@for` aliases compile into scoped runtime chains; aliases do not leak outside the loop element
|
|
263
288
|
- **Component tag case is semantic** - `<Input v-model:value="query">` is a paired component, while lowercase `<input v-model="query">` is a native void element. Keep component tags PascalCase, including names that collide with native elements
|
|
264
289
|
- **Structural DOM timing** - use `nextTick()` with `useRef()` after opening reactive markup
|
|
265
290
|
- **Crosswind for styling** — use utility classes, not inline styles
|
|
@@ -5,7 +5,8 @@ import process from 'node:process'
|
|
|
5
5
|
import { Deployment } from '@stacksjs/orm'
|
|
6
6
|
import { response } from '@stacksjs/router'
|
|
7
7
|
import { dashboardOperationalError, dashboardOperationalIssue } from '../dashboard-response'
|
|
8
|
-
import { booleanValue, deploymentCommandArgs } from './deployment-input'
|
|
8
|
+
import { booleanValue, deploymentCommandArgs, deploymentPreviewCommandArgs } from './deployment-input'
|
|
9
|
+
import { runDeploymentPreview } from './deployment-preview'
|
|
9
10
|
|
|
10
11
|
function gitValue(args: string[]): string {
|
|
11
12
|
const result = Bun.spawnSync(['git', ...args], {
|
|
@@ -26,8 +27,35 @@ export default new Action({
|
|
|
26
27
|
|
|
27
28
|
async handle(request: RequestInstance) {
|
|
28
29
|
const dryRun = booleanValue(request.get('dryRun') || request.get('dry_run'))
|
|
30
|
+
if (dryRun) {
|
|
31
|
+
let previewArgs: string[]
|
|
32
|
+
try {
|
|
33
|
+
previewArgs = deploymentPreviewCommandArgs({
|
|
34
|
+
environment: request.get('environment') || request.get('env'),
|
|
35
|
+
domain: request.get('domain'),
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return response.json({
|
|
40
|
+
success: false,
|
|
41
|
+
message: error instanceof Error ? error.message : 'Deployment preview input is invalid.',
|
|
42
|
+
}, { status: 422 })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
return {
|
|
47
|
+
success: true,
|
|
48
|
+
plan: await runDeploymentPreview(previewArgs),
|
|
49
|
+
message: 'Deployment preview generated.',
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
return dashboardOperationalError(error, 'Deployment preview could not be generated.', 'CreateDeployment.preview', 500)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
29
57
|
const confirmed = booleanValue(request.get('confirmed'))
|
|
30
|
-
if (!
|
|
58
|
+
if (!confirmed) {
|
|
31
59
|
return response.json({
|
|
32
60
|
success: false,
|
|
33
61
|
confirmationRequired: true,
|
|
@@ -40,7 +68,6 @@ export default new Action({
|
|
|
40
68
|
args = deploymentCommandArgs({
|
|
41
69
|
environment: request.get('environment') || request.get('env'),
|
|
42
70
|
domain: request.get('domain'),
|
|
43
|
-
dryRun,
|
|
44
71
|
})
|
|
45
72
|
}
|
|
46
73
|
catch (error) {
|
|
@@ -50,22 +77,20 @@ export default new Action({
|
|
|
50
77
|
}, { status: 422 })
|
|
51
78
|
}
|
|
52
79
|
|
|
53
|
-
let deployment: Awaited<ReturnType<typeof Deployment.create>>
|
|
80
|
+
let deployment: Awaited<ReturnType<typeof Deployment.create>>
|
|
54
81
|
const startedAt = Date.now()
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return dashboardOperationalError(error, 'Deployment record could not be created.', 'CreateDeployment.record', 500)
|
|
68
|
-
}
|
|
82
|
+
try {
|
|
83
|
+
deployment = await Deployment.create({
|
|
84
|
+
commitHash: gitValue(['rev-parse', '--short=12', 'HEAD']),
|
|
85
|
+
commitMessage: gitValue(['log', '-1', '--pretty=%s']),
|
|
86
|
+
branch: gitValue(['branch', '--show-current']),
|
|
87
|
+
status: 'running',
|
|
88
|
+
environment: args[2],
|
|
89
|
+
author: gitValue(['log', '-1', '--pretty=%an <%ae>']),
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
return dashboardOperationalError(error, 'Deployment record could not be created.', 'CreateDeployment.record', 500)
|
|
69
94
|
}
|
|
70
95
|
|
|
71
96
|
let child: ReturnType<typeof Bun.spawn>
|
|
@@ -78,42 +103,37 @@ export default new Action({
|
|
|
78
103
|
child.unref()
|
|
79
104
|
}
|
|
80
105
|
catch (error) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
dashboardOperationalIssue(updateError, 'Deployment failure status could not be recorded.', 'CreateDeployment.failureRecord')
|
|
91
|
-
}
|
|
106
|
+
try {
|
|
107
|
+
await deployment.update({
|
|
108
|
+
status: 'failed',
|
|
109
|
+
duration: 0,
|
|
110
|
+
errorLog: 'Deployment process could not be started.',
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
catch (updateError) {
|
|
114
|
+
dashboardOperationalIssue(updateError, 'Deployment failure status could not be recorded.', 'CreateDeployment.failureRecord')
|
|
92
115
|
}
|
|
93
116
|
return dashboardOperationalError(error, 'Deployment process could not be started.', 'CreateDeployment.process', 500)
|
|
94
117
|
}
|
|
95
118
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
duration: Math.max(0, Math.round((Date.now() - startedAt) / 1000)),
|
|
103
|
-
errorLog: exitCode === 0 ? '' : `buddy deploy exited with code ${exitCode}`,
|
|
104
|
-
})
|
|
105
|
-
})
|
|
106
|
-
.catch((error) => {
|
|
107
|
-
dashboardOperationalIssue(error, 'Deployment completion could not be recorded.', 'CreateDeployment.completionRecord')
|
|
119
|
+
void child.exited
|
|
120
|
+
.then(async (exitCode) => {
|
|
121
|
+
await deployment.update({
|
|
122
|
+
status: exitCode === 0 ? 'success' : 'failed',
|
|
123
|
+
duration: Math.max(0, Math.round((Date.now() - startedAt) / 1000)),
|
|
124
|
+
errorLog: exitCode === 0 ? '' : `buddy deploy exited with code ${exitCode}`,
|
|
108
125
|
})
|
|
109
|
-
|
|
126
|
+
})
|
|
127
|
+
.catch((error) => {
|
|
128
|
+
dashboardOperationalIssue(error, 'Deployment completion could not be recorded.', 'CreateDeployment.completionRecord')
|
|
129
|
+
})
|
|
110
130
|
|
|
111
131
|
return {
|
|
112
132
|
success: true,
|
|
113
133
|
pid: child.pid,
|
|
114
134
|
command: ['./buddy', ...args],
|
|
115
|
-
deployment: deployment
|
|
116
|
-
message:
|
|
135
|
+
deployment: deployment.toJSON(),
|
|
136
|
+
message: 'Deployment started.',
|
|
117
137
|
}
|
|
118
138
|
},
|
|
119
139
|
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { trackOperatorOperation } from '../Operations/control-plane'
|
|
5
|
+
import { stringValue } from '../Operations/recovery-input'
|
|
6
|
+
import { deploymentRollbackInput, DeploymentRollbackError, executeDeploymentRollback } from './deployment-rollback'
|
|
7
|
+
|
|
8
|
+
export default new Action({
|
|
9
|
+
name: 'CreateDeploymentRollback',
|
|
10
|
+
description: 'Executes a previously reviewed deployment rollback and records the operator action.',
|
|
11
|
+
method: 'POST',
|
|
12
|
+
apiResponse: true,
|
|
13
|
+
async handle(request: RequestInstance) {
|
|
14
|
+
const body = request.all() as Record<string, unknown>
|
|
15
|
+
try {
|
|
16
|
+
const input = deploymentRollbackInput(body)
|
|
17
|
+
const tracked = await trackOperatorOperation(request, 'dashboard.deployments.rollback', input, () => executeDeploymentRollback(input, stringValue(body.revision), stringValue(body.confirmation)))
|
|
18
|
+
return { success: true, ...tracked.result, operation: tracked.operation }
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error instanceof DeploymentRollbackError)
|
|
22
|
+
return response.json({ message: error.message }, 409)
|
|
23
|
+
throw error
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
5
|
+
import { deploymentPreviewCommandArgs } from './deployment-input'
|
|
6
|
+
import { runDeploymentPreview } from './deployment-preview'
|
|
7
|
+
|
|
8
|
+
export default new Action({
|
|
9
|
+
name: 'PreviewDeployment',
|
|
10
|
+
description: 'Builds a non-mutating deployment plan from the native Buddy deploy pipeline.',
|
|
11
|
+
method: 'POST',
|
|
12
|
+
apiResponse: true,
|
|
13
|
+
|
|
14
|
+
async handle(request: RequestInstance) {
|
|
15
|
+
let args: string[]
|
|
16
|
+
try {
|
|
17
|
+
args = deploymentPreviewCommandArgs({
|
|
18
|
+
environment: request.get('environment') || request.get('env'),
|
|
19
|
+
domain: request.get('domain'),
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return response.json({
|
|
24
|
+
success: false,
|
|
25
|
+
message: error instanceof Error ? error.message : 'Deployment preview input is invalid.',
|
|
26
|
+
}, { status: 422 })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
return {
|
|
31
|
+
success: true,
|
|
32
|
+
plan: await runDeploymentPreview(args),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return dashboardOperationalError(error, 'Deployment preview could not be generated.', 'PreviewDeployment', 500)
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RequestInstance } from '@stacksjs/types'
|
|
2
|
+
import { Action } from '@stacksjs/actions'
|
|
3
|
+
import { response } from '@stacksjs/router'
|
|
4
|
+
import { dashboardOperationalError } from '../dashboard-response'
|
|
5
|
+
import { deploymentRollbackInput, DeploymentRollbackError, previewDeploymentRollback } from './deployment-rollback'
|
|
6
|
+
|
|
7
|
+
export default new Action({
|
|
8
|
+
name: 'PreviewDeploymentRollback',
|
|
9
|
+
description: 'Builds and validates a native deployment rollback plan without changing the active release.',
|
|
10
|
+
method: 'POST',
|
|
11
|
+
apiResponse: true,
|
|
12
|
+
async handle(request: RequestInstance) {
|
|
13
|
+
try {
|
|
14
|
+
return { success: true, plan: await previewDeploymentRollback(deploymentRollbackInput(request.all() as Record<string, unknown>)) }
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error instanceof DeploymentRollbackError)
|
|
18
|
+
return response.json({ message: error.message }, 409)
|
|
19
|
+
return dashboardOperationalError(error, 'Deployment rollback could not be previewed.', 'PreviewDeploymentRollback')
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
})
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
averageRecordedDuration,
|
|
4
4
|
booleanValue,
|
|
5
5
|
deploymentCommandArgs,
|
|
6
|
+
deploymentPreviewCommandArgs,
|
|
6
7
|
tailLines,
|
|
7
8
|
} from './deployment-input'
|
|
8
9
|
|
|
@@ -19,7 +20,6 @@ describe('dashboard deployment inputs', () => {
|
|
|
19
20
|
expect(deploymentCommandArgs({
|
|
20
21
|
environment: 'staging',
|
|
21
22
|
domain: 'app.example.com',
|
|
22
|
-
dryRun: true,
|
|
23
23
|
})).toEqual([
|
|
24
24
|
'deploy',
|
|
25
25
|
'--env',
|
|
@@ -28,7 +28,6 @@ describe('dashboard deployment inputs', () => {
|
|
|
28
28
|
'--yes',
|
|
29
29
|
'--domain',
|
|
30
30
|
'app.example.com',
|
|
31
|
-
'--dry-run',
|
|
32
31
|
])
|
|
33
32
|
})
|
|
34
33
|
|
|
@@ -37,6 +36,17 @@ describe('dashboard deployment inputs', () => {
|
|
|
37
36
|
expect(() => deploymentCommandArgs({ domain: 'not a domain' })).toThrow()
|
|
38
37
|
})
|
|
39
38
|
|
|
39
|
+
it('builds a non-mutating machine-readable preview command', () => {
|
|
40
|
+
expect(deploymentPreviewCommandArgs({ environment: 'staging' })).toEqual([
|
|
41
|
+
'deploy',
|
|
42
|
+
'--env',
|
|
43
|
+
'staging',
|
|
44
|
+
'--dry-run',
|
|
45
|
+
'--json',
|
|
46
|
+
'--no-interaction',
|
|
47
|
+
])
|
|
48
|
+
})
|
|
49
|
+
|
|
40
50
|
it('aggregates only recorded durations', () => {
|
|
41
51
|
expect(averageRecordedDuration([record(30), record('90'), record(null), record('bad')])).toBe(60)
|
|
42
52
|
expect(averageRecordedDuration([record('bad')])).toBeNull()
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export interface DeploymentCommandInput {
|
|
2
2
|
environment?: unknown
|
|
3
3
|
domain?: unknown
|
|
4
|
-
dryRun?: unknown
|
|
5
4
|
}
|
|
6
5
|
|
|
7
6
|
export interface DeploymentRecordLike {
|
|
@@ -24,11 +23,14 @@ export function deploymentCommandArgs(input: DeploymentCommandInput): string[] {
|
|
|
24
23
|
throw new Error('Domain must be a valid DNS name.')
|
|
25
24
|
args.push('--domain', domain)
|
|
26
25
|
}
|
|
27
|
-
if (booleanValue(input.dryRun))
|
|
28
|
-
args.push('--dry-run')
|
|
29
26
|
return args
|
|
30
27
|
}
|
|
31
28
|
|
|
29
|
+
export function deploymentPreviewCommandArgs(input: DeploymentCommandInput): string[] {
|
|
30
|
+
const args = deploymentCommandArgs(input)
|
|
31
|
+
return [...args.filter(arg => arg !== '--no-interaction' && arg !== '--yes'), '--dry-run', '--json', '--no-interaction']
|
|
32
|
+
}
|
|
33
|
+
|
|
32
34
|
export function averageRecordedDuration(records: DeploymentRecordLike[]): number | null {
|
|
33
35
|
const durations = records
|
|
34
36
|
.map(record => record.get('duration'))
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import type { DeploymentPreview } from '@stacksjs/types'
|
|
3
|
+
import { parseDeploymentPreview } from './deployment-preview'
|
|
4
|
+
|
|
5
|
+
const plan: DeploymentPreview = {
|
|
6
|
+
version: 1,
|
|
7
|
+
dryRun: true,
|
|
8
|
+
project: { name: 'Acme', slug: 'acme' },
|
|
9
|
+
provider: 'hetzner',
|
|
10
|
+
mode: 'server',
|
|
11
|
+
environment: 'production',
|
|
12
|
+
region: 'us-east-1',
|
|
13
|
+
target: { site: null, domain: null, attachTo: null },
|
|
14
|
+
sites: [],
|
|
15
|
+
operations: [],
|
|
16
|
+
warnings: [],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('dashboard deployment preview process', () => {
|
|
20
|
+
it('extracts the versioned contract from Buddy output', () => {
|
|
21
|
+
const output = `[env] loaded\nSTACKS_DEPLOY_PREVIEW_JSON=${JSON.stringify(plan)}\n`
|
|
22
|
+
expect(parseDeploymentPreview(output)).toEqual(plan)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('rejects missing and malformed preview contracts', () => {
|
|
26
|
+
expect(() => parseDeploymentPreview('ordinary output')).toThrow('did not return')
|
|
27
|
+
expect(() => parseDeploymentPreview('STACKS_DEPLOY_PREVIEW_JSON={}')).toThrow('invalid')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { DeploymentPreview } from '@stacksjs/types'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
|
|
5
|
+
const previewPrefix = 'STACKS_DEPLOY_PREVIEW_JSON='
|
|
6
|
+
|
|
7
|
+
export function parseDeploymentPreview(output: string): DeploymentPreview {
|
|
8
|
+
const line = output.split(/\r?\n/).find(entry => entry.startsWith(previewPrefix))
|
|
9
|
+
if (!line)
|
|
10
|
+
throw new Error('Buddy did not return a deployment preview contract.')
|
|
11
|
+
|
|
12
|
+
const plan = JSON.parse(line.slice(previewPrefix.length)) as DeploymentPreview
|
|
13
|
+
if (plan.version !== 1 || plan.dryRun !== true || !Array.isArray(plan.operations) || !Array.isArray(plan.sites))
|
|
14
|
+
throw new Error('Buddy returned an invalid deployment preview contract.')
|
|
15
|
+
return plan
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runDeploymentPreview(args: string[], timeoutMs = 30_000): Promise<DeploymentPreview> {
|
|
19
|
+
const child = Bun.spawn([join(process.cwd(), 'buddy'), ...args], {
|
|
20
|
+
cwd: process.cwd(),
|
|
21
|
+
stdout: 'pipe',
|
|
22
|
+
stderr: 'pipe',
|
|
23
|
+
})
|
|
24
|
+
const stdoutPromise = new Response(child.stdout).text()
|
|
25
|
+
const stderrPromise = new Response(child.stderr).text()
|
|
26
|
+
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
27
|
+
const exitPromise = Promise.race([
|
|
28
|
+
child.exited,
|
|
29
|
+
new Promise<never>((_resolve, reject) => {
|
|
30
|
+
timeout = setTimeout(() => {
|
|
31
|
+
child.kill()
|
|
32
|
+
reject(new Error(`Deployment preview timed out after ${timeoutMs}ms.`))
|
|
33
|
+
}, timeoutMs)
|
|
34
|
+
}),
|
|
35
|
+
])
|
|
36
|
+
const [exitCode, stdout, stderr] = await Promise.all([exitPromise, stdoutPromise, stderrPromise])
|
|
37
|
+
.finally(() => clearTimeout(timeout))
|
|
38
|
+
if (exitCode !== 0)
|
|
39
|
+
throw new Error(stderr.trim() || `buddy deploy preview exited with code ${exitCode}`)
|
|
40
|
+
return parseDeploymentPreview(stdout)
|
|
41
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { deploymentRollbackInput } from './deployment-rollback'
|
|
3
|
+
|
|
4
|
+
describe('deployment rollback input', () => {
|
|
5
|
+
it('normalizes a scoped rollback target', () => {
|
|
6
|
+
expect(deploymentRollbackInput({ environment: 'Staging', site: 'Dashboard', release: 'Release-42' })).toEqual({
|
|
7
|
+
environment: 'staging',
|
|
8
|
+
site: 'dashboard',
|
|
9
|
+
release: 'release-42',
|
|
10
|
+
})
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('rejects shell syntax instead of forwarding it', () => {
|
|
14
|
+
expect(() => deploymentRollbackInput({ environment: 'production; echo unsafe' })).toThrow()
|
|
15
|
+
})
|
|
16
|
+
})
|