@hs-x/cli 0.2.7 → 0.3.6

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 (285) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +25 -8
  3. package/dist/audit-interactive.d.ts +29 -0
  4. package/dist/audit-interactive.d.ts.map +1 -0
  5. package/dist/audit-interactive.js +156 -0
  6. package/dist/audit-interactive.js.map +1 -0
  7. package/dist/bin/hs-x.js.map +1 -1
  8. package/dist/checkpoint-interactive.d.ts +24 -0
  9. package/dist/checkpoint-interactive.d.ts.map +1 -0
  10. package/dist/checkpoint-interactive.js +65 -0
  11. package/dist/checkpoint-interactive.js.map +1 -0
  12. package/dist/cli/index.d.ts.map +1 -1
  13. package/dist/cli/index.js +266 -39
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/cli/kit.d.ts +1 -1
  16. package/dist/cli/kit.d.ts.map +1 -1
  17. package/dist/cli/kit.js +19 -3
  18. package/dist/cli/kit.js.map +1 -1
  19. package/dist/cloudflare-auth.d.ts +8 -0
  20. package/dist/cloudflare-auth.d.ts.map +1 -1
  21. package/dist/cloudflare-auth.js +12 -2
  22. package/dist/cloudflare-auth.js.map +1 -1
  23. package/dist/cloudflare-oauth-store.d.ts +9 -1
  24. package/dist/cloudflare-oauth-store.d.ts.map +1 -1
  25. package/dist/cloudflare-oauth-store.js +23 -4
  26. package/dist/cloudflare-oauth-store.js.map +1 -1
  27. package/dist/cloudflare-oauth.d.ts +1 -0
  28. package/dist/cloudflare-oauth.d.ts.map +1 -1
  29. package/dist/cloudflare-oauth.js +6 -2
  30. package/dist/cloudflare-oauth.js.map +1 -1
  31. package/dist/commands/account.d.ts +28 -13
  32. package/dist/commands/account.d.ts.map +1 -1
  33. package/dist/commands/account.js +219 -69
  34. package/dist/commands/account.js.map +1 -1
  35. package/dist/commands/api.d.ts.map +1 -1
  36. package/dist/commands/api.js +7 -11
  37. package/dist/commands/api.js.map +1 -1
  38. package/dist/commands/completion.d.ts.map +1 -1
  39. package/dist/commands/completion.js +13 -7
  40. package/dist/commands/completion.js.map +1 -1
  41. package/dist/commands/connect.d.ts.map +1 -1
  42. package/dist/commands/connect.js +239 -125
  43. package/dist/commands/connect.js.map +1 -1
  44. package/dist/commands/control-plane-read.d.ts +71 -10
  45. package/dist/commands/control-plane-read.d.ts.map +1 -1
  46. package/dist/commands/control-plane-read.js +1035 -58
  47. package/dist/commands/control-plane-read.js.map +1 -1
  48. package/dist/commands/deploy-promote.d.ts +25 -3
  49. package/dist/commands/deploy-promote.d.ts.map +1 -1
  50. package/dist/commands/deploy-promote.js +314 -38
  51. package/dist/commands/deploy-promote.js.map +1 -1
  52. package/dist/commands/deploy.d.ts +69 -4
  53. package/dist/commands/deploy.d.ts.map +1 -1
  54. package/dist/commands/deploy.js +1512 -276
  55. package/dist/commands/deploy.js.map +1 -1
  56. package/dist/commands/dev.d.ts +5 -1
  57. package/dist/commands/dev.d.ts.map +1 -1
  58. package/dist/commands/dev.js +502 -107
  59. package/dist/commands/dev.js.map +1 -1
  60. package/dist/commands/doctor.d.ts +14 -0
  61. package/dist/commands/doctor.d.ts.map +1 -1
  62. package/dist/commands/doctor.js +131 -20
  63. package/dist/commands/doctor.js.map +1 -1
  64. package/dist/commands/flags.d.ts +108 -6
  65. package/dist/commands/flags.d.ts.map +1 -1
  66. package/dist/commands/flags.js +996 -75
  67. package/dist/commands/flags.js.map +1 -1
  68. package/dist/commands/help-command.d.ts +14 -0
  69. package/dist/commands/help-command.d.ts.map +1 -1
  70. package/dist/commands/help-command.js +144 -44
  71. package/dist/commands/help-command.js.map +1 -1
  72. package/dist/commands/history.js +14 -10
  73. package/dist/commands/history.js.map +1 -1
  74. package/dist/commands/init.d.ts +4 -2
  75. package/dist/commands/init.d.ts.map +1 -1
  76. package/dist/commands/init.js +98 -17
  77. package/dist/commands/init.js.map +1 -1
  78. package/dist/commands/link.d.ts +2 -3
  79. package/dist/commands/link.d.ts.map +1 -1
  80. package/dist/commands/link.js +201 -101
  81. package/dist/commands/link.js.map +1 -1
  82. package/dist/commands/login.d.ts.map +1 -1
  83. package/dist/commands/login.js +22 -5
  84. package/dist/commands/login.js.map +1 -1
  85. package/dist/commands/migrate.d.ts +37 -17
  86. package/dist/commands/migrate.d.ts.map +1 -1
  87. package/dist/commands/migrate.js +500 -73
  88. package/dist/commands/migrate.js.map +1 -1
  89. package/dist/commands/rollback.d.ts +42 -2
  90. package/dist/commands/rollback.d.ts.map +1 -1
  91. package/dist/commands/rollback.js +422 -58
  92. package/dist/commands/rollback.js.map +1 -1
  93. package/dist/commands/secrets.d.ts +76 -3
  94. package/dist/commands/secrets.d.ts.map +1 -1
  95. package/dist/commands/secrets.js +328 -41
  96. package/dist/commands/secrets.js.map +1 -1
  97. package/dist/commands/status.d.ts +13 -2
  98. package/dist/commands/status.d.ts.map +1 -1
  99. package/dist/commands/status.js +154 -17
  100. package/dist/commands/status.js.map +1 -1
  101. package/dist/commands/unlink.d.ts +2 -2
  102. package/dist/commands/unlink.d.ts.map +1 -1
  103. package/dist/commands/unlink.js +2 -2
  104. package/dist/commands/unlink.js.map +1 -1
  105. package/dist/commands/update.d.ts.map +1 -1
  106. package/dist/commands/update.js +4 -3
  107. package/dist/commands/update.js.map +1 -1
  108. package/dist/commands/validate.d.ts +12 -3
  109. package/dist/commands/validate.d.ts.map +1 -1
  110. package/dist/commands/validate.js +70 -8
  111. package/dist/commands/validate.js.map +1 -1
  112. package/dist/constants.d.ts +1 -1
  113. package/dist/constants.js +1 -1
  114. package/dist/control-plane-fetch.d.ts.map +1 -1
  115. package/dist/control-plane-fetch.js +5 -4
  116. package/dist/control-plane-fetch.js.map +1 -1
  117. package/dist/deploy-revision-capture.d.ts +48 -0
  118. package/dist/deploy-revision-capture.d.ts.map +1 -0
  119. package/dist/deploy-revision-capture.js +125 -0
  120. package/dist/deploy-revision-capture.js.map +1 -0
  121. package/dist/dev/event-bus.d.ts +5 -2
  122. package/dist/dev/event-bus.d.ts.map +1 -1
  123. package/dist/dev/event-bus.js +4 -1
  124. package/dist/dev/event-bus.js.map +1 -1
  125. package/dist/dev/hubspot-logs-poller.d.ts +35 -0
  126. package/dist/dev/hubspot-logs-poller.d.ts.map +1 -0
  127. package/dist/dev/hubspot-logs-poller.js +94 -0
  128. package/dist/dev/hubspot-logs-poller.js.map +1 -0
  129. package/dist/dev/invoke.d.ts +9 -1
  130. package/dist/dev/invoke.d.ts.map +1 -1
  131. package/dist/dev/invoke.js +69 -20
  132. package/dist/dev/invoke.js.map +1 -1
  133. package/dist/dev/keyboard.d.ts +31 -0
  134. package/dist/dev/keyboard.d.ts.map +1 -0
  135. package/dist/dev/keyboard.js +143 -0
  136. package/dist/dev/keyboard.js.map +1 -0
  137. package/dist/dev/log-server.d.ts +7 -0
  138. package/dist/dev/log-server.d.ts.map +1 -1
  139. package/dist/dev/log-server.js +68 -31
  140. package/dist/dev/log-server.js.map +1 -1
  141. package/dist/dev/migrated-card.d.ts +17 -0
  142. package/dist/dev/migrated-card.d.ts.map +1 -0
  143. package/dist/dev/migrated-card.js +114 -0
  144. package/dist/dev/migrated-card.js.map +1 -0
  145. package/dist/dev/stream-filter.d.ts +40 -0
  146. package/dist/dev/stream-filter.d.ts.map +1 -0
  147. package/dist/dev/stream-filter.js +93 -0
  148. package/dist/dev/stream-filter.js.map +1 -0
  149. package/dist/dev/stream-renderer.d.ts +34 -0
  150. package/dist/dev/stream-renderer.d.ts.map +1 -1
  151. package/dist/dev/stream-renderer.js +222 -8
  152. package/dist/dev/stream-renderer.js.map +1 -1
  153. package/dist/errors-registry.d.ts.map +1 -1
  154. package/dist/errors-registry.js +280 -36
  155. package/dist/errors-registry.js.map +1 -1
  156. package/dist/errors.d.ts.map +1 -1
  157. package/dist/errors.js.map +1 -1
  158. package/dist/help.d.ts.map +1 -1
  159. package/dist/help.js +8 -0
  160. package/dist/help.js.map +1 -1
  161. package/dist/hubspot-app-logs.d.ts +76 -0
  162. package/dist/hubspot-app-logs.d.ts.map +1 -0
  163. package/dist/hubspot-app-logs.js +323 -0
  164. package/dist/hubspot-app-logs.js.map +1 -0
  165. package/dist/hubspot-auth.d.ts +8 -2
  166. package/dist/hubspot-auth.d.ts.map +1 -1
  167. package/dist/hubspot-auth.js +9 -9
  168. package/dist/hubspot-auth.js.map +1 -1
  169. package/dist/hubspot-developer-client.js +119 -11
  170. package/dist/hubspot-developer-client.js.map +1 -1
  171. package/dist/index.d.ts +1 -0
  172. package/dist/index.d.ts.map +1 -1
  173. package/dist/index.js +1 -0
  174. package/dist/index.js.map +1 -1
  175. package/dist/init/templates.d.ts +11 -1
  176. package/dist/init/templates.d.ts.map +1 -1
  177. package/dist/init/templates.js +228 -15
  178. package/dist/init/templates.js.map +1 -1
  179. package/dist/link-migration-journal.d.ts +131 -0
  180. package/dist/link-migration-journal.d.ts.map +1 -0
  181. package/dist/link-migration-journal.js +395 -0
  182. package/dist/link-migration-journal.js.map +1 -0
  183. package/dist/link-preflight.d.ts +78 -0
  184. package/dist/link-preflight.d.ts.map +1 -0
  185. package/dist/link-preflight.js +231 -0
  186. package/dist/link-preflight.js.map +1 -0
  187. package/dist/local-project-picker.d.ts +34 -0
  188. package/dist/local-project-picker.d.ts.map +1 -0
  189. package/dist/local-project-picker.js +121 -0
  190. package/dist/local-project-picker.js.map +1 -0
  191. package/dist/logs-interactive.d.ts +37 -0
  192. package/dist/logs-interactive.d.ts.map +1 -0
  193. package/dist/logs-interactive.js +110 -0
  194. package/dist/logs-interactive.js.map +1 -0
  195. package/dist/output-context.d.ts +13 -0
  196. package/dist/output-context.d.ts.map +1 -0
  197. package/dist/output-context.js +46 -0
  198. package/dist/output-context.js.map +1 -0
  199. package/dist/project-context.d.ts +55 -0
  200. package/dist/project-context.d.ts.map +1 -0
  201. package/dist/project-context.js +115 -0
  202. package/dist/project-context.js.map +1 -0
  203. package/dist/project-picker.d.ts +65 -0
  204. package/dist/project-picker.d.ts.map +1 -0
  205. package/dist/project-picker.js +227 -0
  206. package/dist/project-picker.js.map +1 -0
  207. package/dist/prompt.d.ts +8 -0
  208. package/dist/prompt.d.ts.map +1 -1
  209. package/dist/prompt.js +81 -13
  210. package/dist/prompt.js.map +1 -1
  211. package/dist/reporter/human.d.ts +10 -2
  212. package/dist/reporter/human.d.ts.map +1 -1
  213. package/dist/reporter/human.js +82 -14
  214. package/dist/reporter/human.js.map +1 -1
  215. package/dist/reporter/index.d.ts.map +1 -1
  216. package/dist/reporter/index.js +7 -5
  217. package/dist/reporter/index.js.map +1 -1
  218. package/dist/reporter/json.d.ts +2 -1
  219. package/dist/reporter/json.d.ts.map +1 -1
  220. package/dist/reporter/json.js +10 -7
  221. package/dist/reporter/json.js.map +1 -1
  222. package/dist/reporter/style.d.ts +19 -0
  223. package/dist/reporter/style.d.ts.map +1 -1
  224. package/dist/reporter/style.js +126 -6
  225. package/dist/reporter/style.js.map +1 -1
  226. package/dist/reporter/types.d.ts +34 -4
  227. package/dist/reporter/types.d.ts.map +1 -1
  228. package/dist/reporter/types.js.map +1 -1
  229. package/dist/services/account-store.d.ts +3 -1
  230. package/dist/services/account-store.d.ts.map +1 -1
  231. package/dist/services/account-store.js +2 -1
  232. package/dist/services/account-store.js.map +1 -1
  233. package/dist/services/app-paths.d.ts.map +1 -1
  234. package/dist/services/app-paths.js +3 -1
  235. package/dist/services/app-paths.js.map +1 -1
  236. package/dist/services/cloudflare-auth.d.ts +1 -1
  237. package/dist/services/cloudflare-auth.d.ts.map +1 -1
  238. package/dist/services/cloudflare-auth.js +2 -2
  239. package/dist/services/cloudflare-auth.js.map +1 -1
  240. package/dist/services/cloudflare-kv.d.ts.map +1 -1
  241. package/dist/services/cloudflare-kv.js.map +1 -1
  242. package/dist/services/command-history.d.ts.map +1 -1
  243. package/dist/services/command-history.js.map +1 -1
  244. package/dist/services/config.d.ts.map +1 -1
  245. package/dist/services/config.js +1 -1
  246. package/dist/services/config.js.map +1 -1
  247. package/dist/services/control-plane.d.ts.map +1 -1
  248. package/dist/services/control-plane.js +3 -3
  249. package/dist/services/control-plane.js.map +1 -1
  250. package/dist/services/env-loader.d.ts.map +1 -1
  251. package/dist/services/env-loader.js +1 -3
  252. package/dist/services/env-loader.js.map +1 -1
  253. package/dist/services/http.d.ts.map +1 -1
  254. package/dist/services/http.js.map +1 -1
  255. package/dist/services/hubspot-dev-client.d.ts.map +1 -1
  256. package/dist/services/hubspot-dev-client.js +30 -0
  257. package/dist/services/hubspot-dev-client.js.map +1 -1
  258. package/dist/services/live.d.ts +2 -2
  259. package/dist/services/live.d.ts.map +1 -1
  260. package/dist/services/live.js +1 -1
  261. package/dist/services/live.js.map +1 -1
  262. package/dist/services/machine-id.d.ts.map +1 -1
  263. package/dist/services/machine-id.js.map +1 -1
  264. package/dist/services/reporter.d.ts +8 -5
  265. package/dist/services/reporter.d.ts.map +1 -1
  266. package/dist/services/reporter.js +6 -4
  267. package/dist/services/reporter.js.map +1 -1
  268. package/dist/services/tenant-state.d.ts.map +1 -1
  269. package/dist/services/tenant-state.js.map +1 -1
  270. package/dist/state-store.d.ts +4 -0
  271. package/dist/state-store.d.ts.map +1 -1
  272. package/dist/state-store.js +15 -0
  273. package/dist/state-store.js.map +1 -1
  274. package/dist/status-drilldown.d.ts +24 -0
  275. package/dist/status-drilldown.d.ts.map +1 -0
  276. package/dist/status-drilldown.js +50 -0
  277. package/dist/status-drilldown.js.map +1 -0
  278. package/dist/tenant-state.d.ts.map +1 -1
  279. package/dist/tenant-state.js +3 -2
  280. package/dist/tenant-state.js.map +1 -1
  281. package/dist/token-key-custody.d.ts +91 -0
  282. package/dist/token-key-custody.d.ts.map +1 -0
  283. package/dist/token-key-custody.js +375 -0
  284. package/dist/token-key-custody.js.map +1 -0
  285. package/package.json +14 -15
@@ -1,10 +1,13 @@
1
1
  import * as Command from '@effect/cli/Command';
2
2
  import * as Options from '@effect/cli/Options';
3
3
  import { HttpClientRequest, Schema, schemas } from '@hs-x/types';
4
+ import { runTrafficRollback } from '@hs-x/types/rollback';
4
5
  import { Config, Effect, Option } from 'effect';
5
- import { loadStore } from '../account-store.js';
6
- import { CliError, cliError, exitWith } from '../cli-error.js';
6
+ import { cliError, exitWith } from '../cli-error.js';
7
7
  import { accountIdOption, controlPlaneUrlOption, cwdOption, deployIdOption, forceOption, jsonOption, projectIdOption, runHandler, userIdOption, yesOption, } from '../cli/kit.js';
8
+ import { readLocalProjectContext, resolveProjectId } from '../project-context.js';
9
+ import { ProjectPickerCancelled, resolveAccountId, resolveProjectSelection, } from '../project-picker.js';
10
+ import { isInteractive, promptConfirm, promptSelect, } from '../prompt.js';
8
11
  import { AccountStore } from '../services/account-store.js';
9
12
  import { CloudflareAuth } from '../services/cloudflare-auth.js';
10
13
  import { ControlPlaneClient } from '../services/control-plane.js';
@@ -15,13 +18,19 @@ import { Reporter } from '../services/reporter.js';
15
18
  import { TenantState } from '../services/tenant-state.js';
16
19
  const environmentOption = Options.text('environment').pipe(Options.withAlias('env'), Options.optional);
17
20
  // ─── Native @effect/cli command (Phase 4) ───────────────────────────────────
18
- // `rollback` moves the active deploy pointer. Linked: control-plane POST via
19
- // `ControlPlaneClient.authHeaders` + `CliHttp`. Unlinked: rewrites
21
+ // `rollback` restores live traffic to a prior deploy. Linked (ADR-021 R04):
22
+ // TRAFFIC-FIRST a membership-gated control-plane PLAN resolves the verified
23
+ // target + operation id (`ControlPlaneClient.authHeaders` + `CliHttp`), then the
24
+ // R01 Worker Versions/Deployments adapter creates a 100% Cloudflare deployment
25
+ // of that version and health-probes it, and only THEN a COMMIT records the
26
+ // verbatim outcome + advances the active pointer. Unlinked: rewrites
20
27
  // `LocalActiveDeploy` in tenant KV through `TenantState` (pointer + credentials
21
28
  // resolved via `CloudflareAuth.loadPointer` and `Config`), with `MachineId` for
22
- // the audit stamp. Interactive prompts are skipped under --json / non-TTY (and
23
- // quarantined behind the legacy prompt helpers otherwise), so the subprocess
24
- // tests' exact stdout/exit assertions are preserved.
29
+ // the audit stamp bookkeeping only, honestly labelled (unlinked deploys carry
30
+ // no captured Cloudflare version, so there is no verified target to serve).
31
+ // Interactive prompts are skipped under --json / non-TTY (and quarantined behind
32
+ // the legacy prompt helpers otherwise), so the subprocess tests' exact
33
+ // stdout/exit assertions are preserved.
25
34
  //
26
35
  // JSON-shape MISMATCH (flagged, NOT changed): every `--json` branch emits a
27
36
  // bespoke pretty-printed object (e.g. `{ ok, command:'rollback', linkState,
@@ -43,6 +52,16 @@ export const rollbackCmd = Command.make('rollback', {
43
52
  }, (opts) => runHandler('rollback', { json: opts.json }, rollbackBody(opts)));
44
53
  function rollbackBody(opts) {
45
54
  return Effect.gen(function* () {
55
+ const reporter = yield* Reporter;
56
+ const needsExplicitConsent = !opts.yes && (opts.json || reporter.mode === 'ndjson' || !isInteractive());
57
+ if (needsExplicitConsent) {
58
+ const message = 'Rollback requires explicit consent in machine or non-interactive mode. Re-run with --yes.';
59
+ if (opts.json) {
60
+ yield* reporter.raw(`${JSON.stringify({ ok: false, error: message }, null, 2)}\n`);
61
+ return yield* Effect.fail(exitWith(1, 'HSX_E_INPUT_INVALID'));
62
+ }
63
+ return yield* Effect.fail(cliError('HSX_E_INPUT_INVALID', message));
64
+ }
46
65
  const store = yield* AccountStore;
47
66
  const envControlPlaneUrl = yield* Config.string('HSX_CONTROL_PLANE_URL').pipe(Config.withDefault(''), Effect.orDie);
48
67
  const explicitControlPlaneUrl = Option.getOrUndefined(opts.controlPlaneUrl) ?? (envControlPlaneUrl || undefined);
@@ -60,17 +79,55 @@ function rollbackLinkedEff(opts, explicitControlPlaneUrl) {
60
79
  return Effect.gen(function* () {
61
80
  const controlPlane = yield* ControlPlaneClient;
62
81
  const reporter = yield* Reporter;
63
- const resolvedAcct = yield* resolveAccountIdEff(opts);
64
- const projectId = yield* resolveWithEnv(opts.projectId, 'HSX_PROJECT_ID');
65
- const accountId = (yield* resolveWithEnv(opts.accountId, 'HSX_ACCOUNT_ID')) ?? resolvedAcct;
82
+ const accounts = yield* AccountStore;
83
+ const cwd = yield* Cwd;
84
+ const root = Option.getOrElse(opts.cwd, () => cwd);
85
+ const local = yield* Effect.promise(() => readLocalProjectContext(root));
86
+ const stored = yield* accounts.load;
87
+ const envAccountId = yield* resolveEnv('HSX_ACCOUNT_ID');
88
+ const envProjectId = yield* resolveEnv('HSX_PROJECT_ID');
89
+ const explicitAccountId = Option.getOrUndefined(opts.accountId);
90
+ const accountId = resolveAccountId({
91
+ ...(explicitAccountId ? { explicitAccountId } : {}),
92
+ ...(envAccountId ? { envAccountId } : {}),
93
+ ...(local.projectBinding?.accountId
94
+ ? { localAccountId: local.projectBinding.accountId }
95
+ : {}),
96
+ ...(stored.defaultAccountId ? { defaultAccountId: stored.defaultAccountId } : {}),
97
+ });
98
+ const resolvedProject = resolveProjectId({
99
+ explicit: Option.getOrUndefined(opts.projectId),
100
+ env: envProjectId,
101
+ local,
102
+ });
66
103
  const controlPlaneUrl = explicitControlPlaneUrl;
67
- const targetDeployId = Option.getOrUndefined(opts.deployId);
68
- if (!projectId || !accountId || !controlPlaneUrl) {
69
- return yield* Effect.fail(cliError('HSX_E_INTERNAL', 'Linked rollback requires --project-id/HSX_PROJECT_ID, --account-id/HSX_ACCOUNT_ID, and --control-plane-url/HSX_CONTROL_PLANE_URL.'));
104
+ const allowDiscovery = !opts.json && reporter.mode !== 'ndjson' && isInteractive();
105
+ // Automation never makes discovery-only calls: all authoritative context
106
+ // must come from flags, environment, or the checkout binding, and consent
107
+ // must already have been supplied by --yes (enforced before this function).
108
+ if (!accountId || !controlPlaneUrl || (!resolvedProject && !allowDiscovery)) {
109
+ return yield* Effect.fail(cliError('HSX_E_INTERNAL', 'Linked rollback requires --project-id/HSX_PROJECT_ID (or a linked project directory), --account-id/HSX_ACCOUNT_ID, and --control-plane-url/HSX_CONTROL_PLANE_URL. A human TTY may omit project and deploy to choose them interactively.'));
70
110
  }
71
111
  const userId = yield* resolveUserIdEff(opts.userId);
72
112
  const headers = yield* controlPlane.authHeaders(userId);
73
- const rollback = yield* requestControlPlaneRollbackEff({
113
+ const environment = Option.getOrUndefined(opts.environment) ??
114
+ (yield* resolveEnv('HSX_ENVIRONMENT')) ??
115
+ 'production';
116
+ const discovery = yield* resolveLinkedRollbackDiscoveryEff({
117
+ accountId,
118
+ controlPlaneUrl,
119
+ headers,
120
+ authoritativeProjectId: resolvedProject?.value,
121
+ localProjectId: local.projectBinding?.projectId,
122
+ targetDeployId: Option.getOrUndefined(opts.deployId),
123
+ environment,
124
+ allowDiscovery,
125
+ });
126
+ const projectId = discovery.projectId;
127
+ const targetDeployId = discovery.targetDeployId;
128
+ // Phase 1 — membership-gated PLAN (no traffic change). A non-member/cross-
129
+ // account caller is rejected here, so no Cloudflare work is reachable.
130
+ const plan = yield* requestRollbackPlanEff({
74
131
  controlPlaneUrl,
75
132
  projectId,
76
133
  accountId,
@@ -78,22 +135,189 @@ function rollbackLinkedEff(opts, explicitControlPlaneUrl) {
78
135
  force: opts.force,
79
136
  headers,
80
137
  });
138
+ // The authorized PLAN is read-only and resolves the exact target/version
139
+ // plus deterministic operation id. Show that target before credentials or
140
+ // any live traffic change. --force is visible as a compatibility override;
141
+ // it never doubles as consent.
142
+ if (!opts.json) {
143
+ const preview = linkedRollbackPreview({
144
+ projectId: plan.projectId,
145
+ fromDeployId: plan.fromDeploy.deployId,
146
+ targetDeployId: plan.targetDeploy.deployId,
147
+ targetVersionId: plan.revision.versionId,
148
+ operationId: plan.operationId,
149
+ warnings: plan.warnings,
150
+ force: opts.force,
151
+ });
152
+ if (reporter.mode === 'ndjson') {
153
+ yield* reporter.event('rollback.preview', preview.data);
154
+ }
155
+ else {
156
+ yield* reporter.header(projectId);
157
+ yield* reporter.block(preview.text);
158
+ }
159
+ }
160
+ const confirmation = yield* Effect.promise(() => confirmRollbackOperation({
161
+ yes: opts.yes,
162
+ machineOutput: opts.json || reporter.mode === 'ndjson',
163
+ message: `Move live traffic from ${plan.fromDeploy.deployId} to ${plan.targetDeploy.deployId}?`,
164
+ }));
165
+ if (confirmation !== 'proceed') {
166
+ if (confirmation === 'require-yes') {
167
+ return yield* Effect.fail(cliError('HSX_E_INPUT_INVALID', 'Rollback requires explicit consent in machine or non-interactive mode. Re-run with --yes.'));
168
+ }
169
+ yield* reporter.info('Cancelled.');
170
+ return yield* Effect.fail(exitWith(130));
171
+ }
172
+ // Cloudflare credentials for the real traffic change (ADR-021: metadata-only
173
+ // rollback is retired — a rollback that cannot reach Cloudflare must fail
174
+ // rather than silently claim a traffic change it did not make).
175
+ const cf = yield* resolveCloudflareRollbackCredsEff();
176
+ // Phase 2 — TRAFFIC-FIRST: create the 100% Cloudflare deployment of the
177
+ // target version, health-probe it, restore-on-failure. Verbatim outcome.
178
+ const outcome = yield* Effect.tryPromise({
179
+ try: () => runTrafficRollback({ ...(cf.baseUrl ? { baseUrl: cf.baseUrl } : {}) }, { plan, apiToken: cf.apiToken, force: opts.force }),
180
+ catch: (cause) => cliError('HSX_E_ROLLBACK_FAILED', `Cloudflare rollback failed: ${String(cause)}`),
181
+ });
182
+ // Phase 3 — RECORD-SECOND: commit the outcome. The control plane advances
183
+ // its active pointer only for confirmed-live statuses.
184
+ const commit = yield* commitRollbackEff({
185
+ controlPlaneUrl,
186
+ projectId,
187
+ accountId,
188
+ operation: outcome.operation,
189
+ headers,
190
+ });
191
+ if (!outcome.ok) {
192
+ const detail = outcome.operation.detail ?? 'Rollback did not complete.';
193
+ if (opts.json) {
194
+ yield* reporter.raw(`${JSON.stringify({
195
+ ok: false,
196
+ command: 'rollback',
197
+ linkState: 'linked',
198
+ mode: 'cloudflare-traffic-rollback',
199
+ status: outcome.operation.status,
200
+ operation: outcome.operation,
201
+ error: detail,
202
+ }, null, 2)}\n`);
203
+ return yield* Effect.fail(exitWith(1, 'HSX_E_ROLLBACK_FAILED'));
204
+ }
205
+ return yield* Effect.fail(cliError('HSX_E_ROLLBACK_FAILED', `${detail} (status: ${outcome.operation.status})`));
206
+ }
81
207
  const result = {
82
208
  ok: true,
83
209
  command: 'rollback',
84
210
  linkState: 'linked',
85
- mode: 'control-plane-rollback',
86
- rollback,
211
+ mode: 'cloudflare-traffic-rollback',
212
+ status: outcome.operation.status,
213
+ liveVersionId: outcome.liveVersionId,
214
+ liveDeploymentId: outcome.liveDeploymentId,
215
+ operation: outcome.operation,
216
+ ...(commit.rollback ? { rollback: commit.rollback } : {}),
87
217
  };
88
218
  if (opts.json) {
89
219
  yield* reporter.raw(`${JSON.stringify(result, null, 2)}\n`);
90
220
  return;
91
221
  }
92
222
  yield* reporter.header(projectId);
93
- yield* reporter.block(renderControlPlaneRollback(rollback));
223
+ yield* reporter.block(renderTrafficRollback(outcome, commit));
94
224
  yield* reporter.done('Rolled back');
95
225
  });
96
226
  }
227
+ /**
228
+ * Eligible human rollback choices, newest first. History-only deploys have no
229
+ * verified Worker revision and the active deploy cannot be its own target.
230
+ * Compatibility/adjacency remains authoritative in the subsequent PLAN.
231
+ */
232
+ export function linkedRollbackRevisionOptions(read) {
233
+ return read.revisions
234
+ .filter((record) => record.deployId !== read.activeDeployId && record.revision !== undefined)
235
+ .sort((left, right) => right.recordedAt.localeCompare(left.recordedAt))
236
+ .map((record) => ({
237
+ value: record.deployId,
238
+ label: record.deployId,
239
+ description: `${record.recordedAt} — ${record.status}`,
240
+ }));
241
+ }
242
+ function resolveLinkedRollbackDiscoveryEff(input) {
243
+ return Effect.gen(function* () {
244
+ let projectId = input.authoritativeProjectId;
245
+ let projectLabel = projectId;
246
+ if (!projectId) {
247
+ const projects = yield* fetchRollbackProjectsEff(input);
248
+ const project = yield* Effect.tryPromise({
249
+ try: () => resolveProjectSelection({
250
+ accountId: input.accountId,
251
+ projects,
252
+ ...(input.localProjectId ? { localProjectId: input.localProjectId } : {}),
253
+ allowPrompt: input.allowDiscovery,
254
+ prompts: {
255
+ select: (options) => promptSelect(options),
256
+ },
257
+ message: 'Which project do you want to roll back?',
258
+ }),
259
+ catch: rollbackDiscoveryError,
260
+ });
261
+ projectId = project.id;
262
+ projectLabel = project.displayName;
263
+ }
264
+ if (!projectId) {
265
+ return yield* Effect.fail(cliError('HSX_E_INTERNAL', 'Linked rollback could not resolve a project.'));
266
+ }
267
+ let targetDeployId = input.targetDeployId;
268
+ if (!targetDeployId && input.allowDiscovery) {
269
+ const revisions = yield* fetchRollbackRevisionsEff({ ...input, projectId });
270
+ const options = linkedRollbackRevisionOptions(revisions);
271
+ if (options.length === 0) {
272
+ return yield* Effect.fail(cliError('HSX_E_ROLLBACK_FAILED', `No prior revision-backed deploy is available to roll back ${projectLabel ?? projectId}.`));
273
+ }
274
+ targetDeployId = yield* Effect.promise(() => promptSelect({
275
+ message: `Which deploy should ${projectLabel ?? projectId}/${input.environment} roll back to?`,
276
+ options,
277
+ }));
278
+ if (!targetDeployId)
279
+ return yield* Effect.fail(exitWith(130));
280
+ }
281
+ return { projectId, targetDeployId };
282
+ });
283
+ }
284
+ function fetchRollbackProjectsEff(input) {
285
+ return Effect.gen(function* () {
286
+ const body = yield* rollbackDiscoveryGetEff(input, `/v1/accounts/${encodeURIComponent(input.accountId)}/projects`, 'Project discovery failed');
287
+ return yield* decodeRollbackDiscoveryEff(schemas.ListProjectsResponse, body).pipe(Effect.map((decoded) => decoded.projects));
288
+ });
289
+ }
290
+ function fetchRollbackRevisionsEff(input) {
291
+ return Effect.gen(function* () {
292
+ const body = yield* rollbackDiscoveryGetEff(input, `/v1/projects/${encodeURIComponent(input.projectId)}/revisions`, 'Revision discovery failed');
293
+ return yield* decodeRollbackDiscoveryEff(schemas.ProjectRevisionRead, body);
294
+ });
295
+ }
296
+ function rollbackDiscoveryGetEff(input, path, fallback) {
297
+ return Effect.gen(function* () {
298
+ const http = yield* CliHttp;
299
+ const request = HttpClientRequest.make('GET')(new URL(path, input.controlPlaneUrl)).pipe(HttpClientRequest.setHeaders(input.headers));
300
+ const response = yield* http.execute(request);
301
+ const body = yield* Effect.promise(() => response.json().catch(() => undefined));
302
+ if (!response.ok) {
303
+ return yield* Effect.fail(cliError('HSX_E_ROLLBACK_FAILED', isRecord(body) && typeof body.message === 'string'
304
+ ? body.message
305
+ : `${fallback} with status ${response.status}`));
306
+ }
307
+ return body;
308
+ });
309
+ }
310
+ function decodeRollbackDiscoveryEff(schema, body) {
311
+ return Effect.try({
312
+ try: () => Schema.decodeUnknownSync(schema)(body),
313
+ catch: (cause) => cliError('HSX_E_ROLLBACK_FAILED', `Invalid discovery response: ${cause}`),
314
+ });
315
+ }
316
+ function rollbackDiscoveryError(cause) {
317
+ return cause instanceof ProjectPickerCancelled
318
+ ? exitWith(130)
319
+ : cliError('HSX_E_ROLLBACK_FAILED', cause instanceof Error ? cause.message : String(cause));
320
+ }
97
321
  function rollbackUnlinkedEff(opts) {
98
322
  return Effect.gen(function* () {
99
323
  const cwd = yield* Cwd;
@@ -114,19 +338,48 @@ function rollbackUnlinkedEff(opts) {
114
338
  });
115
339
  const projectId = yield* resolveWithEnv(opts.projectId, 'HSX_PROJECT_ID');
116
340
  const environment = Option.getOrElse(opts.environment, () => 'production');
117
- const targetDeployId = Option.getOrUndefined(opts.deployId);
118
341
  if (!projectId) {
119
342
  return yield* failBespoke('Unlinked rollback requires --project-id (or HSX_PROJECT_ID). Linked rollback also accepts --account-id + --control-plane-url; run `hs-x link` to use it.');
120
343
  }
121
- if (!targetDeployId) {
122
- return yield* failBespoke('Unlinked rollback requires --deploy-id (the prior deploy to make active). Use `hs-x history` or check `.hs-x/` deploy entries for valid ids.');
123
- }
124
344
  const pointer = yield* cloudflare.loadPointer(root);
125
345
  if (!pointer) {
126
346
  return yield* failBespoke('No `.hs-x/cloudflare.json` found in this project. Unlinked rollback can only operate on projects that have been deployed at least once.');
127
347
  }
128
348
  const credentials = yield* resolveTenantCredentialsEff(pointer.cloudflareAccountId);
129
349
  const stateStore = yield* tenantState.store({ pointer, credentials });
350
+ // Resolve the deploy to make active: the explicit --deploy-id, else an
351
+ // interactive picker over prior recorded deploys for this environment.
352
+ // `--json` / non-TTY skip the prompt (promptSelect returns undefined off a
353
+ // TTY) and fall through to the clear missing-argument error below.
354
+ let targetDeployId = Option.getOrUndefined(opts.deployId);
355
+ let pickerOpened = false;
356
+ if (!targetDeployId && !opts.json && isInteractive()) {
357
+ const activeNow = yield* stateStore.getActive({ projectId, environment });
358
+ const recorded = yield* stateStore.listDeploys({ projectId });
359
+ const candidates = recorded
360
+ .filter((entry) => entry.environment === environment &&
361
+ entry.status === 'deployed' &&
362
+ entry.deployId !== activeNow?.deployId)
363
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0));
364
+ if (candidates.length > 0) {
365
+ pickerOpened = true;
366
+ targetDeployId = yield* Effect.promise(() => promptSelect({
367
+ message: `Which deploy should become active for ${projectId}/${environment}?`,
368
+ options: candidates.map((entry) => ({
369
+ value: entry.deployId,
370
+ label: entry.deployId,
371
+ description: entry.updatedAt,
372
+ })),
373
+ }));
374
+ }
375
+ }
376
+ if (!targetDeployId && pickerOpened) {
377
+ yield* reporter.info('Cancelled.');
378
+ return yield* Effect.fail(exitWith(130));
379
+ }
380
+ if (!targetDeployId) {
381
+ return yield* failBespoke('Unlinked rollback requires --deploy-id (the prior deploy to make active). Use `hs-x history` or check `.hs-x/` deploy entries for valid ids.');
382
+ }
130
383
  const target = yield* stateStore.getDeploy({ projectId, deployId: targetDeployId });
131
384
  if (!target) {
132
385
  return yield* failBespoke(`Deploy ${targetDeployId} not found in tenant state for owner ${pointer.ownerId} (project ${projectId}).`);
@@ -155,6 +408,33 @@ function rollbackUnlinkedEff(opts) {
155
408
  yield* reporter.done('No change');
156
409
  return;
157
410
  }
411
+ const preview = unlinkedRollbackPreview({
412
+ projectId,
413
+ environment,
414
+ previousDeployId: previousActive?.deployId,
415
+ targetDeployId,
416
+ });
417
+ if (!opts.json) {
418
+ if (reporter.mode === 'ndjson') {
419
+ yield* reporter.event('rollback.preview', preview.data);
420
+ }
421
+ else {
422
+ yield* reporter.header(projectId);
423
+ yield* reporter.block(preview.text);
424
+ }
425
+ }
426
+ const confirmation = yield* Effect.promise(() => confirmRollbackOperation({
427
+ yes: opts.yes,
428
+ machineOutput: opts.json || reporter.mode === 'ndjson',
429
+ message: `Update the ${projectId}/${environment} active pointer to ${targetDeployId}?`,
430
+ }));
431
+ if (confirmation !== 'proceed') {
432
+ if (confirmation === 'require-yes') {
433
+ return yield* Effect.fail(cliError('HSX_E_INPUT_INVALID', 'Rollback requires explicit consent in machine or non-interactive mode. Re-run with --yes.'));
434
+ }
435
+ yield* reporter.info('Cancelled.');
436
+ return yield* Effect.fail(exitWith(130));
437
+ }
158
438
  const now = yield* Effect.sync(() => new Date().toISOString());
159
439
  const active = Schema.decodeUnknownSync(schemas.LocalActiveDeploy)({
160
440
  ownerId: pointer.ownerId,
@@ -208,21 +488,8 @@ function resolveUserIdEff(flag) {
208
488
  return env || 'local-cli-user';
209
489
  });
210
490
  }
211
- /**
212
- * Non-interactive account-id resolution for linked rollback: flag/env, else the
213
- * stored default account when present. The interactive picker the legacy used in
214
- * a TTY is deferred to the legacy path (see `resolveAccountIdOrPrompt`).
215
- */
216
- function resolveAccountIdEff(opts) {
217
- return Effect.gen(function* () {
218
- const fromFlagEnv = yield* resolveWithEnv(opts.accountId, 'HSX_ACCOUNT_ID');
219
- if (fromFlagEnv)
220
- return fromFlagEnv;
221
- const store = yield* Effect.promise(() => loadStore());
222
- const ids = Object.keys(store.accounts);
223
- const defaultId = store.defaultAccountId;
224
- return defaultId && ids.includes(defaultId) ? defaultId : undefined;
225
- });
491
+ function resolveEnv(key) {
492
+ return Config.string(key).pipe(Config.withDefault(''), Effect.orDie, Effect.map((value) => value || undefined));
226
493
  }
227
494
  /** Resolve Cloudflare API credentials for tenant KV (env-only, via `Config`). */
228
495
  function resolveTenantCredentialsEff(cloudflareAccountId) {
@@ -243,16 +510,27 @@ function resolveTenantCredentialsEff(cloudflareAccountId) {
243
510
  };
244
511
  });
245
512
  }
246
- /** Effect-native control-plane rollback POST over `CliHttp`. */
247
- function requestControlPlaneRollbackEff(input) {
513
+ /** Resolve tenant Cloudflare credentials for the real traffic change (env-only). */
514
+ function resolveCloudflareRollbackCredsEff() {
515
+ return Effect.gen(function* () {
516
+ const env = yield* Config.all({
517
+ tokenHsx: Config.string('HSX_CLOUDFLARE_API_TOKEN').pipe(Config.withDefault('')),
518
+ token: Config.string('CLOUDFLARE_API_TOKEN').pipe(Config.withDefault('')),
519
+ baseUrl: Config.string('HSX_CLOUDFLARE_API_BASE_URL').pipe(Config.withDefault('')),
520
+ }).pipe(Effect.orDie);
521
+ const apiToken = env.tokenHsx || env.token || undefined;
522
+ if (!apiToken) {
523
+ return yield* Effect.fail(cliError('HSX_E_ROLLBACK_CLOUDFLARE_CREDENTIALS', 'Rollback restores live Cloudflare traffic and needs a Cloudflare API token. Set HSX_CLOUDFLARE_API_TOKEN (or CLOUDFLARE_API_TOKEN).'));
524
+ }
525
+ return { apiToken, ...(env.baseUrl ? { baseUrl: env.baseUrl } : {}) };
526
+ });
527
+ }
528
+ /** Phase 1: membership-gated rollback PLAN POST over `CliHttp` (no traffic change). */
529
+ function requestRollbackPlanEff(input) {
248
530
  return Effect.gen(function* () {
249
531
  const http = yield* CliHttp;
250
- const url = new URL(`/v1/projects/${encodeURIComponent(input.projectId)}/rollback`, input.controlPlaneUrl);
251
- const headers = {
252
- ...input.headers,
253
- 'content-type': 'application/json',
254
- };
255
- const request = HttpClientRequest.make('POST')(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.bodyText(JSON.stringify({
532
+ const url = new URL(`/v1/projects/${encodeURIComponent(input.projectId)}/rollback/plan`, input.controlPlaneUrl);
533
+ const request = HttpClientRequest.make('POST')(url).pipe(HttpClientRequest.setHeaders({ ...input.headers, 'content-type': 'application/json' }), HttpClientRequest.bodyText(JSON.stringify({
256
534
  accountId: input.accountId,
257
535
  ...(input.targetDeployId ? { targetDeployId: input.targetDeployId } : {}),
258
536
  ...(input.force ? { force: input.force } : {}),
@@ -260,41 +538,127 @@ function requestControlPlaneRollbackEff(input) {
260
538
  const response = yield* http.execute(request);
261
539
  const body = yield* Effect.promise(() => response.json().catch(() => undefined));
262
540
  if (!response.ok) {
263
- return yield* Effect.fail(cliError('HSX_E_INTERNAL', isRecord(body) && typeof body.message === 'string'
541
+ return yield* Effect.fail(cliError('HSX_E_ROLLBACK_FAILED', isRecord(body) && typeof body.message === 'string'
264
542
  ? body.message
265
- : `Control-plane rollback failed with status ${response.status}`));
543
+ : `Rollback plan failed with status ${response.status}`));
266
544
  }
267
545
  return yield* Effect.try({
268
- try: () => Schema.decodeUnknownSync(schemas.DeployRollbackResult)(body),
269
- catch: (cause) => cliError('HSX_E_INTERNAL', String(cause)),
546
+ try: () => Schema.decodeUnknownSync(schemas.DeployRollbackPlan)(body),
547
+ catch: (cause) => cliError('HSX_E_ROLLBACK_FAILED', String(cause)),
270
548
  });
271
549
  });
272
550
  }
273
- function renderControlPlaneRollback(rollback) {
551
+ /** Phase 3: commit the traffic-first rollback outcome POST over `CliHttp`. */
552
+ function commitRollbackEff(input) {
553
+ return Effect.gen(function* () {
554
+ const http = yield* CliHttp;
555
+ const url = new URL(`/v1/projects/${encodeURIComponent(input.projectId)}/rollback/commit`, input.controlPlaneUrl);
556
+ const request = HttpClientRequest.make('POST')(url).pipe(HttpClientRequest.setHeaders({ ...input.headers, 'content-type': 'application/json' }), HttpClientRequest.bodyText(JSON.stringify({ accountId: input.accountId, operation: input.operation }), 'application/json'));
557
+ const response = yield* http.execute(request);
558
+ const body = yield* Effect.promise(() => response.json().catch(() => undefined));
559
+ if (!response.ok) {
560
+ return yield* Effect.fail(cliError('HSX_E_ROLLBACK_FAILED', isRecord(body) && typeof body.message === 'string'
561
+ ? body.message
562
+ : `Rollback commit failed with status ${response.status}`));
563
+ }
564
+ return yield* Effect.try({
565
+ try: () => Schema.decodeUnknownSync(schemas.DeployRollbackCommitResult)(body),
566
+ catch: (cause) => cliError('HSX_E_ROLLBACK_FAILED', String(cause)),
567
+ });
568
+ });
569
+ }
570
+ function renderTrafficRollback(outcome, commit) {
274
571
  const lines = [
275
- `Rolled back ${rollback.projectId}`,
276
- `Previous active: ${rollback.rolledBackFrom.deployId}`,
277
- `New active: ${rollback.active.deployId}`,
572
+ `Rolled back ${outcome.operation.projectId}`,
573
+ `Now live: Cloudflare version ${outcome.liveVersionId}`,
574
+ `Cloudflare deployment: ${outcome.liveDeploymentId}`,
278
575
  ];
279
- for (const warning of rollback.warnings) {
280
- lines.push(`Warning: ${warning}`);
576
+ if (commit.rollback) {
577
+ lines.push(`Previous active deploy: ${commit.rollback.rolledBackFrom.deployId}`);
578
+ lines.push(`New active deploy: ${commit.rollback.active.deployId}`);
579
+ for (const warning of commit.rollback.warnings) {
580
+ lines.push(`Warning: ${warning}`);
581
+ }
582
+ }
583
+ if (outcome.alreadyApplied) {
584
+ lines.push('(idempotent: the target version was already live; reconciled without a duplicate deployment)');
281
585
  }
282
586
  return `${lines.join('\n')}\n`;
283
587
  }
588
+ const liveRollbackConfirmationDeps = {
589
+ isInteractive,
590
+ confirm: promptConfirm,
591
+ };
592
+ /** Purely coordinates consent; --force is intentionally not an input. */
593
+ export async function confirmRollbackOperation(input) {
594
+ if (input.yes)
595
+ return 'proceed';
596
+ const deps = input.deps ?? liveRollbackConfirmationDeps;
597
+ if (input.machineOutput || !deps.isInteractive())
598
+ return 'require-yes';
599
+ const confirmed = await deps.confirm({ message: input.message, default: false });
600
+ return confirmed === true ? 'proceed' : 'cancel';
601
+ }
602
+ export function linkedRollbackPreview(input) {
603
+ const lines = [
604
+ 'Rollback preview (live traffic)',
605
+ `Project: ${input.projectId}`,
606
+ `Current deploy: ${input.fromDeployId}`,
607
+ `Target deploy: ${input.targetDeployId}`,
608
+ `Target Worker version: ${input.targetVersionId}`,
609
+ `Operation id: ${input.operationId}`,
610
+ `Compatibility override: ${input.force ? 'enabled (--force)' : 'off'}`,
611
+ ...input.warnings.map((warning) => `Warning: ${warning}`),
612
+ ];
613
+ return {
614
+ text: `${lines.join('\n')}\n`,
615
+ data: {
616
+ mode: 'cloudflare-traffic-rollback',
617
+ project_id: input.projectId,
618
+ from_deploy_id: input.fromDeployId,
619
+ target_deploy_id: input.targetDeployId,
620
+ target_version_id: input.targetVersionId,
621
+ operation_id: input.operationId,
622
+ force: input.force,
623
+ warnings: input.warnings,
624
+ },
625
+ };
626
+ }
627
+ export function unlinkedRollbackPreview(input) {
628
+ return {
629
+ text: `Rollback preview (tenant pointer only)\nProject: ${input.projectId}\nEnvironment: ${input.environment}\nCurrent active: ${input.previousDeployId ?? '(none)'}\nTarget active: ${input.targetDeployId}\nLive traffic is not redirected by this operation.\n`,
630
+ data: {
631
+ mode: 'tenant-kv-rollback',
632
+ project_id: input.projectId,
633
+ environment: input.environment,
634
+ from_deploy_id: input.previousDeployId ?? null,
635
+ target_deploy_id: input.targetDeployId,
636
+ effect: 'tenant-active-pointer-only',
637
+ },
638
+ };
639
+ }
284
640
  export function rollbackHelpText() {
285
641
  return `hs-x rollback
286
642
 
287
643
  Linked (after \`hs-x login\`):
288
- hs-x rollback --account-id <id> --project-id <id> --deploy-id <id> [--force] [--json]
289
- POSTs to control-plane; rewrites the active deploy pointer and updates routing.
644
+ hs-x rollback --account-id <id> --project-id <id> --deploy-id <id> [--force] [--yes] [--json]
645
+ In a human TTY, project and deploy may be omitted: rollback chooses from the
646
+ account's projects by display name, then prior revision-backed deploys.
647
+ Restores LIVE Cloudflare traffic to the target deploy's verified Worker
648
+ version (creates a 100% deployment of it, health-probes it), then records the
649
+ outcome and advances the active pointer. If the probe fails, traffic is
650
+ restored to the pre-rollback version and the attempt is recorded as failed.
651
+ Needs a Cloudflare API token (HSX_CLOUDFLARE_API_TOKEN / CLOUDFLARE_API_TOKEN).
290
652
 
291
653
  Unlinked (default):
292
- hs-x rollback --project-id <id> --deploy-id <id> [--environment <env>] [--cwd <dir>]
654
+ hs-x rollback --project-id <id> --deploy-id <id> [--environment <env>] [--cwd <dir>] [--yes]
293
655
  Updates the active deploy pointer in tenant Cloudflare KV. Without revisioned
294
656
  routing this is a bookkeeping change; redeploy or manually re-route Cloudflare
295
657
  traffic if you want the rollback to take effect on live requests.
296
658
 
297
- Interactive prompts apply when run in a TTY without --yes/--force.
659
+ Interactive rollback confirmation defaults to No and is bypassed only by --yes.
660
+ --force overrides target compatibility safeguards; it does not imply consent.
661
+ JSON, JSON-stream, and non-TTY invocations never prompt and require --yes to mutate.
298
662
  \t`;
299
663
  }
300
664
  function isRecord(value) {