@hs-x/cli 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +25 -8
- package/dist/audit-interactive.d.ts +29 -0
- package/dist/audit-interactive.d.ts.map +1 -0
- package/dist/audit-interactive.js +156 -0
- package/dist/audit-interactive.js.map +1 -0
- package/dist/bin/hs-x.js.map +1 -1
- package/dist/checkpoint-interactive.d.ts +24 -0
- package/dist/checkpoint-interactive.d.ts.map +1 -0
- package/dist/checkpoint-interactive.js +65 -0
- package/dist/checkpoint-interactive.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +266 -39
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/kit.d.ts +1 -1
- package/dist/cli/kit.d.ts.map +1 -1
- package/dist/cli/kit.js +19 -3
- package/dist/cli/kit.js.map +1 -1
- package/dist/cloudflare-auth.d.ts +8 -0
- package/dist/cloudflare-auth.d.ts.map +1 -1
- package/dist/cloudflare-auth.js +12 -2
- package/dist/cloudflare-auth.js.map +1 -1
- package/dist/cloudflare-oauth-store.d.ts +9 -1
- package/dist/cloudflare-oauth-store.d.ts.map +1 -1
- package/dist/cloudflare-oauth-store.js +23 -4
- package/dist/cloudflare-oauth-store.js.map +1 -1
- package/dist/cloudflare-oauth.d.ts +1 -0
- package/dist/cloudflare-oauth.d.ts.map +1 -1
- package/dist/cloudflare-oauth.js +6 -2
- package/dist/cloudflare-oauth.js.map +1 -1
- package/dist/cloudflare-worker-versions.d.ts +184 -0
- package/dist/cloudflare-worker-versions.d.ts.map +1 -0
- package/dist/cloudflare-worker-versions.js +360 -0
- package/dist/cloudflare-worker-versions.js.map +1 -0
- package/dist/commands/account.d.ts +28 -13
- package/dist/commands/account.d.ts.map +1 -1
- package/dist/commands/account.js +219 -69
- package/dist/commands/account.js.map +1 -1
- package/dist/commands/api.d.ts +1 -1
- package/dist/commands/api.d.ts.map +1 -1
- package/dist/commands/api.js +6 -10
- package/dist/commands/api.js.map +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/completion.js +13 -7
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +239 -125
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/control-plane-read.d.ts +72 -11
- package/dist/commands/control-plane-read.d.ts.map +1 -1
- package/dist/commands/control-plane-read.js +1035 -58
- package/dist/commands/control-plane-read.js.map +1 -1
- package/dist/commands/deploy-promote.d.ts +25 -3
- package/dist/commands/deploy-promote.d.ts.map +1 -1
- package/dist/commands/deploy-promote.js +314 -38
- package/dist/commands/deploy-promote.js.map +1 -1
- package/dist/commands/deploy.d.ts +67 -4
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +1333 -255
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts +5 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +502 -107
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +15 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +131 -20
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/flags.d.ts +108 -6
- package/dist/commands/flags.d.ts.map +1 -1
- package/dist/commands/flags.js +996 -75
- package/dist/commands/flags.js.map +1 -1
- package/dist/commands/help-command.d.ts +14 -0
- package/dist/commands/help-command.d.ts.map +1 -1
- package/dist/commands/help-command.js +144 -44
- package/dist/commands/help-command.js.map +1 -1
- package/dist/commands/history.js +14 -10
- package/dist/commands/history.js.map +1 -1
- package/dist/commands/init.d.ts +4 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +69 -12
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/link.d.ts +2 -3
- package/dist/commands/link.d.ts.map +1 -1
- package/dist/commands/link.js +201 -101
- package/dist/commands/link.js.map +1 -1
- package/dist/commands/login.d.ts +2 -2
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +22 -5
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/migrate.d.ts +23 -3
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +500 -73
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/rollback.d.ts +42 -2
- package/dist/commands/rollback.d.ts.map +1 -1
- package/dist/commands/rollback.js +422 -58
- package/dist/commands/rollback.js.map +1 -1
- package/dist/commands/secrets.d.ts +76 -3
- package/dist/commands/secrets.d.ts.map +1 -1
- package/dist/commands/secrets.js +328 -41
- package/dist/commands/secrets.js.map +1 -1
- package/dist/commands/status.d.ts +13 -2
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +154 -17
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/unlink.d.ts +2 -2
- package/dist/commands/unlink.d.ts.map +1 -1
- package/dist/commands/unlink.js +2 -2
- package/dist/commands/unlink.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +4 -3
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/validate.d.ts +12 -3
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +70 -8
- package/dist/commands/validate.js.map +1 -1
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/control-plane-fetch.d.ts.map +1 -1
- package/dist/control-plane-fetch.js +5 -4
- package/dist/control-plane-fetch.js.map +1 -1
- package/dist/deploy-revision-capture.d.ts +48 -0
- package/dist/deploy-revision-capture.d.ts.map +1 -0
- package/dist/deploy-revision-capture.js +125 -0
- package/dist/deploy-revision-capture.js.map +1 -0
- package/dist/dev/compat-shim.d.ts.map +1 -1
- package/dist/dev/compat-shim.js +10 -35
- package/dist/dev/compat-shim.js.map +1 -1
- package/dist/dev/event-bus.d.ts +5 -2
- package/dist/dev/event-bus.d.ts.map +1 -1
- package/dist/dev/event-bus.js +4 -1
- package/dist/dev/event-bus.js.map +1 -1
- package/dist/dev/hubspot-logs-poller.d.ts +35 -0
- package/dist/dev/hubspot-logs-poller.d.ts.map +1 -0
- package/dist/dev/hubspot-logs-poller.js +94 -0
- package/dist/dev/hubspot-logs-poller.js.map +1 -0
- package/dist/dev/invoke.d.ts +9 -1
- package/dist/dev/invoke.d.ts.map +1 -1
- package/dist/dev/invoke.js +69 -20
- package/dist/dev/invoke.js.map +1 -1
- package/dist/dev/keyboard.d.ts +31 -0
- package/dist/dev/keyboard.d.ts.map +1 -0
- package/dist/dev/keyboard.js +143 -0
- package/dist/dev/keyboard.js.map +1 -0
- package/dist/dev/log-server.d.ts +7 -0
- package/dist/dev/log-server.d.ts.map +1 -1
- package/dist/dev/log-server.js +68 -31
- package/dist/dev/log-server.js.map +1 -1
- package/dist/dev/migrated-card.d.ts +17 -0
- package/dist/dev/migrated-card.d.ts.map +1 -0
- package/dist/dev/migrated-card.js +114 -0
- package/dist/dev/migrated-card.js.map +1 -0
- package/dist/dev/stream-filter.d.ts +40 -0
- package/dist/dev/stream-filter.d.ts.map +1 -0
- package/dist/dev/stream-filter.js +93 -0
- package/dist/dev/stream-filter.js.map +1 -0
- package/dist/dev/stream-renderer.d.ts +34 -0
- package/dist/dev/stream-renderer.d.ts.map +1 -1
- package/dist/dev/stream-renderer.js +222 -8
- package/dist/dev/stream-renderer.js.map +1 -1
- package/dist/errors-registry.d.ts.map +1 -1
- package/dist/errors-registry.js +280 -36
- package/dist/errors-registry.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +8 -0
- package/dist/help.js.map +1 -1
- package/dist/hubspot-app-logs.d.ts +76 -0
- package/dist/hubspot-app-logs.d.ts.map +1 -0
- package/dist/hubspot-app-logs.js +323 -0
- package/dist/hubspot-app-logs.js.map +1 -0
- package/dist/hubspot-auth.d.ts +8 -2
- package/dist/hubspot-auth.d.ts.map +1 -1
- package/dist/hubspot-auth.js +36 -12
- package/dist/hubspot-auth.js.map +1 -1
- package/dist/hubspot-developer-client.js +119 -11
- package/dist/hubspot-developer-client.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/init/templates.d.ts +10 -0
- package/dist/init/templates.d.ts.map +1 -1
- package/dist/init/templates.js +157 -14
- package/dist/init/templates.js.map +1 -1
- package/dist/link-migration-journal.d.ts +131 -0
- package/dist/link-migration-journal.d.ts.map +1 -0
- package/dist/link-migration-journal.js +395 -0
- package/dist/link-migration-journal.js.map +1 -0
- package/dist/link-preflight.d.ts +78 -0
- package/dist/link-preflight.d.ts.map +1 -0
- package/dist/link-preflight.js +231 -0
- package/dist/link-preflight.js.map +1 -0
- package/dist/local-project-picker.d.ts +34 -0
- package/dist/local-project-picker.d.ts.map +1 -0
- package/dist/local-project-picker.js +121 -0
- package/dist/local-project-picker.js.map +1 -0
- package/dist/logs-interactive.d.ts +37 -0
- package/dist/logs-interactive.d.ts.map +1 -0
- package/dist/logs-interactive.js +110 -0
- package/dist/logs-interactive.js.map +1 -0
- package/dist/output-context.d.ts +13 -0
- package/dist/output-context.d.ts.map +1 -0
- package/dist/output-context.js +46 -0
- package/dist/output-context.js.map +1 -0
- package/dist/project-context.d.ts +55 -0
- package/dist/project-context.d.ts.map +1 -0
- package/dist/project-context.js +115 -0
- package/dist/project-context.js.map +1 -0
- package/dist/project-picker.d.ts +65 -0
- package/dist/project-picker.d.ts.map +1 -0
- package/dist/project-picker.js +227 -0
- package/dist/project-picker.js.map +1 -0
- package/dist/prompt.d.ts +8 -0
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +81 -13
- package/dist/prompt.js.map +1 -1
- package/dist/reporter/human.d.ts +10 -2
- package/dist/reporter/human.d.ts.map +1 -1
- package/dist/reporter/human.js +82 -14
- package/dist/reporter/human.js.map +1 -1
- package/dist/reporter/index.d.ts.map +1 -1
- package/dist/reporter/index.js +7 -5
- package/dist/reporter/index.js.map +1 -1
- package/dist/reporter/json.d.ts +2 -1
- package/dist/reporter/json.d.ts.map +1 -1
- package/dist/reporter/json.js +10 -7
- package/dist/reporter/json.js.map +1 -1
- package/dist/reporter/style.d.ts +19 -0
- package/dist/reporter/style.d.ts.map +1 -1
- package/dist/reporter/style.js +126 -6
- package/dist/reporter/style.js.map +1 -1
- package/dist/reporter/types.d.ts +34 -4
- package/dist/reporter/types.d.ts.map +1 -1
- package/dist/reporter/types.js.map +1 -1
- package/dist/rollback-traffic.d.ts +85 -0
- package/dist/rollback-traffic.d.ts.map +1 -0
- package/dist/rollback-traffic.js +204 -0
- package/dist/rollback-traffic.js.map +1 -0
- package/dist/services/account-store.d.ts +3 -1
- package/dist/services/account-store.d.ts.map +1 -1
- package/dist/services/account-store.js +2 -1
- package/dist/services/account-store.js.map +1 -1
- package/dist/services/app-paths.d.ts.map +1 -1
- package/dist/services/app-paths.js +3 -1
- package/dist/services/app-paths.js.map +1 -1
- package/dist/services/cloudflare-auth.d.ts +1 -1
- package/dist/services/cloudflare-auth.d.ts.map +1 -1
- package/dist/services/cloudflare-auth.js +2 -2
- package/dist/services/cloudflare-auth.js.map +1 -1
- package/dist/services/cloudflare-kv.d.ts.map +1 -1
- package/dist/services/cloudflare-kv.js.map +1 -1
- package/dist/services/command-history.d.ts.map +1 -1
- package/dist/services/command-history.js.map +1 -1
- package/dist/services/config.d.ts.map +1 -1
- package/dist/services/config.js +1 -1
- package/dist/services/config.js.map +1 -1
- package/dist/services/control-plane.d.ts.map +1 -1
- package/dist/services/control-plane.js +3 -3
- package/dist/services/control-plane.js.map +1 -1
- package/dist/services/env-loader.d.ts.map +1 -1
- package/dist/services/env-loader.js +1 -3
- package/dist/services/env-loader.js.map +1 -1
- package/dist/services/http.d.ts.map +1 -1
- package/dist/services/http.js.map +1 -1
- package/dist/services/hubspot-dev-client.d.ts.map +1 -1
- package/dist/services/hubspot-dev-client.js +30 -0
- package/dist/services/hubspot-dev-client.js.map +1 -1
- package/dist/services/live.d.ts +2 -2
- package/dist/services/live.d.ts.map +1 -1
- package/dist/services/live.js +1 -1
- package/dist/services/live.js.map +1 -1
- package/dist/services/machine-id.d.ts.map +1 -1
- package/dist/services/machine-id.js.map +1 -1
- package/dist/services/reporter.d.ts +8 -5
- package/dist/services/reporter.d.ts.map +1 -1
- package/dist/services/reporter.js +6 -4
- package/dist/services/reporter.js.map +1 -1
- package/dist/services/tenant-state.d.ts.map +1 -1
- package/dist/services/tenant-state.js.map +1 -1
- package/dist/state-store.d.ts +4 -0
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +15 -0
- package/dist/state-store.js.map +1 -1
- package/dist/status-drilldown.d.ts +24 -0
- package/dist/status-drilldown.d.ts.map +1 -0
- package/dist/status-drilldown.js +50 -0
- package/dist/status-drilldown.js.map +1 -0
- package/dist/tenant-state.d.ts.map +1 -1
- package/dist/tenant-state.js +3 -2
- package/dist/tenant-state.js.map +1 -1
- package/dist/token-key-custody.d.ts +91 -0
- package/dist/token-key-custody.d.ts.map +1 -0
- package/dist/token-key-custody.js +375 -0
- package/dist/token-key-custody.js.map +1 -0
- package/package.json +13 -14
|
@@ -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 {
|
|
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`
|
|
19
|
-
//
|
|
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
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
|
64
|
-
const
|
|
65
|
-
const
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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: '
|
|
86
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
/**
|
|
247
|
-
function
|
|
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
|
|
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('
|
|
541
|
+
return yield* Effect.fail(cliError('HSX_E_ROLLBACK_FAILED', isRecord(body) && typeof body.message === 'string'
|
|
264
542
|
? body.message
|
|
265
|
-
: `
|
|
543
|
+
: `Rollback plan failed with status ${response.status}`));
|
|
266
544
|
}
|
|
267
545
|
return yield* Effect.try({
|
|
268
|
-
try: () => Schema.decodeUnknownSync(schemas.
|
|
269
|
-
catch: (cause) => cliError('
|
|
546
|
+
try: () => Schema.decodeUnknownSync(schemas.DeployRollbackPlan)(body),
|
|
547
|
+
catch: (cause) => cliError('HSX_E_ROLLBACK_FAILED', String(cause)),
|
|
270
548
|
});
|
|
271
549
|
});
|
|
272
550
|
}
|
|
273
|
-
|
|
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 ${
|
|
276
|
-
`
|
|
277
|
-
`
|
|
572
|
+
`Rolled back ${outcome.operation.projectId}`,
|
|
573
|
+
`Now live: Cloudflare version ${outcome.liveVersionId}`,
|
|
574
|
+
`Cloudflare deployment: ${outcome.liveDeploymentId}`,
|
|
278
575
|
];
|
|
279
|
-
|
|
280
|
-
lines.push(`
|
|
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
|
-
|
|
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
|
|
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) {
|