@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.
Files changed (79) hide show
  1. package/ai/skills/stacks-browse/SKILL.md +9 -1
  2. package/ai/skills/stacks-dashboard/SKILL.md +49 -1
  3. package/ai/skills/stacks-deploy/SKILL.md +18 -0
  4. package/ai/skills/stacks-desktop/SKILL.md +11 -2
  5. package/ai/skills/stacks-stx/SKILL.md +25 -0
  6. package/app/Actions/Dashboard/Deployments/CreateDeployment.ts +64 -44
  7. package/app/Actions/Dashboard/Deployments/CreateDeploymentRollback.ts +26 -0
  8. package/app/Actions/Dashboard/Deployments/PreviewDeployment.ts +39 -0
  9. package/app/Actions/Dashboard/Deployments/PreviewDeploymentRollback.ts +22 -0
  10. package/app/Actions/Dashboard/Deployments/deployment-input.test.ts +12 -2
  11. package/app/Actions/Dashboard/Deployments/deployment-input.ts +5 -3
  12. package/app/Actions/Dashboard/Deployments/deployment-preview.test.ts +29 -0
  13. package/app/Actions/Dashboard/Deployments/deployment-preview.ts +41 -0
  14. package/app/Actions/Dashboard/Deployments/deployment-rollback.test.ts +16 -0
  15. package/app/Actions/Dashboard/Deployments/deployment-rollback.ts +89 -0
  16. package/app/Actions/Dashboard/Operations/AuditIndexAction.ts +25 -0
  17. package/app/Actions/Dashboard/Operations/ChangeApprovalAction.ts +28 -0
  18. package/app/Actions/Dashboard/Operations/ChangeIndexAction.ts +32 -0
  19. package/app/Actions/Dashboard/Operations/IncidentIndexAction.ts +32 -0
  20. package/app/Actions/Dashboard/Operations/IncidentUpdateAction.ts +34 -0
  21. package/app/Actions/Dashboard/Operations/MigrationApplyAction.ts +30 -0
  22. package/app/Actions/Dashboard/Operations/MigrationIndexAction.ts +24 -0
  23. package/app/Actions/Dashboard/Operations/MigrationReconcileAction.ts +23 -0
  24. package/app/Actions/Dashboard/Operations/RecoveryDestinationStoreAction.ts +91 -0
  25. package/app/Actions/Dashboard/Operations/RecoveryDestinationTestAction.ts +34 -0
  26. package/app/Actions/Dashboard/Operations/RecoveryIndexAction.ts +58 -0
  27. package/app/Actions/Dashboard/Operations/RecoveryPolicyStoreAction.ts +90 -0
  28. package/app/Actions/Dashboard/Operations/RecoveryProtectAction.ts +32 -0
  29. package/app/Actions/Dashboard/Operations/RecoveryRestoreAction.ts +72 -0
  30. package/app/Actions/Dashboard/Operations/RecoveryRetentionAction.ts +17 -0
  31. package/app/Actions/Dashboard/Operations/RecoveryRunAction.ts +25 -0
  32. package/app/Actions/Dashboard/Operations/RecoveryVerifyAction.ts +24 -0
  33. package/app/Actions/Dashboard/Operations/SchedulerIndexAction.ts +34 -0
  34. package/app/Actions/Dashboard/Operations/SchedulerRunAction.ts +33 -0
  35. package/app/Actions/Dashboard/Operations/SchedulerToggleAction.ts +36 -0
  36. package/app/Actions/Dashboard/Operations/control-plane.ts +150 -0
  37. package/app/Actions/Dashboard/Operations/migration-operations.ts +80 -0
  38. package/app/Actions/Dashboard/Operations/operations-runtime.ts +11 -0
  39. package/app/Actions/Dashboard/Operations/recovery-input.test.ts +22 -0
  40. package/app/Actions/Dashboard/Operations/recovery-input.ts +33 -0
  41. package/app/Actions/Dashboard/Operations/recovery-runtime.ts +99 -0
  42. package/app/Actions/Dashboard/Operations/scheduler-operations.test.ts +23 -0
  43. package/app/Actions/Dashboard/Operations/scheduler-operations.ts +138 -0
  44. package/functions/deployments.ts +18 -0
  45. package/functions/operations.ts +361 -0
  46. package/ide/vscode/package.json +1 -1
  47. package/package.json +1 -1
  48. package/resources/components/Dashboard/Ci/CiDashboard.stx +4 -4
  49. package/resources/components/Dashboard/Deployments/DeploymentList.stx +147 -14
  50. package/resources/components/Dashboard/Deployments/DeploymentPreviewDialog.stx +118 -0
  51. package/resources/components/Dashboard/GlobalSearch.stx +87 -91
  52. package/resources/components/Dashboard/Operations/AuditDashboard.stx +16 -0
  53. package/resources/components/Dashboard/Operations/ChangeDashboard.stx +66 -0
  54. package/resources/components/Dashboard/Operations/IncidentDashboard.stx +39 -0
  55. package/resources/components/Dashboard/Operations/MigrationDashboard.stx +146 -0
  56. package/resources/components/Dashboard/Operations/OperationHistory.stx +61 -0
  57. package/resources/components/Dashboard/Operations/OperationsNavigation.stx +32 -0
  58. package/resources/components/Dashboard/Operations/RecoveryDashboard.stx +229 -0
  59. package/resources/components/Dashboard/Operations/RecoveryDestinationDialog.stx +126 -0
  60. package/resources/components/Dashboard/Operations/RecoveryJobList.stx +32 -0
  61. package/resources/components/Dashboard/Operations/RecoveryPointTable.stx +82 -0
  62. package/resources/components/Dashboard/Operations/RecoveryPolicyDialog.stx +155 -0
  63. package/resources/components/Dashboard/Operations/RecoveryRestoreDialog.stx +90 -0
  64. package/resources/components/Dashboard/Operations/SchedulerDashboard.stx +144 -0
  65. package/resources/components/Dashboard/Operations/SchedulerTaskTable.stx +81 -0
  66. package/resources/components/Dashboard/Settings/SettingsDashboard.stx +45 -3
  67. package/resources/components/Dashboard/UI/Modal.stx +33 -8
  68. package/resources/functions/dashboard/sidebar.ts +1 -0
  69. package/routes/dashboard-api.ts +23 -0
  70. package/routes/dashboard.ts +1 -0
  71. package/vcs/github/workflows/ci.yml +3 -3
  72. package/vcs/github/workflows/release.yml +1 -1
  73. package/views/dashboard/operations/audit.stx +5 -0
  74. package/views/dashboard/operations/changes.stx +5 -0
  75. package/views/dashboard/operations/incidents.stx +5 -0
  76. package/views/dashboard/operations/migrations.stx +8 -0
  77. package/views/dashboard/operations/recovery.stx +8 -0
  78. package/views/dashboard/operations/scheduler.stx +8 -0
  79. 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`. Script reads and atomic writes use
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
- Set `CRAFT_BIN` to an executable Craft binary in CI or when Craft is not checked
27
- out at `~/Code/Tools/craft`.
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 (!dryRun && !confirmed) {
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>> | null = null
80
+ let deployment: Awaited<ReturnType<typeof Deployment.create>>
54
81
  const startedAt = Date.now()
55
- if (!dryRun) {
56
- try {
57
- deployment = await Deployment.create({
58
- commitHash: gitValue(['rev-parse', '--short=12', 'HEAD']),
59
- commitMessage: gitValue(['log', '-1', '--pretty=%s']),
60
- branch: gitValue(['branch', '--show-current']),
61
- status: 'running',
62
- environment: args[2],
63
- author: gitValue(['log', '-1', '--pretty=%an <%ae>']),
64
- })
65
- }
66
- catch (error) {
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
- if (deployment) {
82
- try {
83
- await deployment.update({
84
- status: 'failed',
85
- duration: 0,
86
- errorLog: 'Deployment process could not be started.',
87
- })
88
- }
89
- catch (updateError) {
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
- if (deployment) {
97
- const record = deployment
98
- void child.exited
99
- .then(async (exitCode) => {
100
- await record.update({
101
- status: exitCode === 0 ? 'success' : 'failed',
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?.toJSON() || null,
116
- message: dryRun ? 'Deployment preview started.' : 'Deployment started.',
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
+ })