@hs-x/cli 0.3.6 → 0.4.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.
Files changed (211) hide show
  1. package/README.md +126 -0
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +58 -59
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/cli/kit.d.ts.map +1 -1
  6. package/dist/cli/kit.js +1 -1
  7. package/dist/cli/kit.js.map +1 -1
  8. package/dist/cloudflare-auth.d.ts +19 -0
  9. package/dist/cloudflare-auth.d.ts.map +1 -1
  10. package/dist/cloudflare-auth.js +83 -2
  11. package/dist/cloudflare-auth.js.map +1 -1
  12. package/dist/cloudflare-oauth.d.ts.map +1 -1
  13. package/dist/cloudflare-oauth.js +18 -19
  14. package/dist/cloudflare-oauth.js.map +1 -1
  15. package/dist/cloudflare-scoped-resource.d.ts +41 -0
  16. package/dist/cloudflare-scoped-resource.d.ts.map +1 -0
  17. package/dist/cloudflare-scoped-resource.js +74 -0
  18. package/dist/cloudflare-scoped-resource.js.map +1 -0
  19. package/dist/command-catalog.d.ts +13 -0
  20. package/dist/command-catalog.d.ts.map +1 -0
  21. package/dist/command-catalog.js +50 -0
  22. package/dist/command-catalog.js.map +1 -0
  23. package/dist/commands/completion.d.ts.map +1 -1
  24. package/dist/commands/completion.js +287 -47
  25. package/dist/commands/completion.js.map +1 -1
  26. package/dist/commands/connect.d.ts.map +1 -1
  27. package/dist/commands/connect.js +274 -38
  28. package/dist/commands/connect.js.map +1 -1
  29. package/dist/commands/control-plane-read.d.ts +31 -3
  30. package/dist/commands/control-plane-read.d.ts.map +1 -1
  31. package/dist/commands/control-plane-read.js +136 -37
  32. package/dist/commands/control-plane-read.js.map +1 -1
  33. package/dist/commands/deploy-retry.d.ts +20 -0
  34. package/dist/commands/deploy-retry.d.ts.map +1 -0
  35. package/dist/commands/deploy-retry.js +106 -0
  36. package/dist/commands/deploy-retry.js.map +1 -0
  37. package/dist/commands/deploy.d.ts +16 -8
  38. package/dist/commands/deploy.d.ts.map +1 -1
  39. package/dist/commands/deploy.js +1027 -373
  40. package/dist/commands/deploy.js.map +1 -1
  41. package/dist/commands/dev.d.ts +19 -1
  42. package/dist/commands/dev.d.ts.map +1 -1
  43. package/dist/commands/dev.js +1378 -88
  44. package/dist/commands/dev.js.map +1 -1
  45. package/dist/commands/doctor.d.ts.map +1 -1
  46. package/dist/commands/doctor.js +27 -10
  47. package/dist/commands/doctor.js.map +1 -1
  48. package/dist/commands/flags.d.ts +9 -4
  49. package/dist/commands/flags.d.ts.map +1 -1
  50. package/dist/commands/flags.js +208 -80
  51. package/dist/commands/flags.js.map +1 -1
  52. package/dist/commands/help-command.d.ts.map +1 -1
  53. package/dist/commands/help-command.js +177 -86
  54. package/dist/commands/help-command.js.map +1 -1
  55. package/dist/commands/init.d.ts.map +1 -1
  56. package/dist/commands/init.js +5 -0
  57. package/dist/commands/init.js.map +1 -1
  58. package/dist/commands/link.d.ts.map +1 -1
  59. package/dist/commands/link.js +74 -15
  60. package/dist/commands/link.js.map +1 -1
  61. package/dist/commands/project.d.ts +107 -0
  62. package/dist/commands/project.d.ts.map +1 -0
  63. package/dist/commands/project.js +586 -0
  64. package/dist/commands/project.js.map +1 -0
  65. package/dist/commands/secrets.d.ts +2 -2
  66. package/dist/commands/status.d.ts.map +1 -1
  67. package/dist/commands/status.js +15 -5
  68. package/dist/commands/status.js.map +1 -1
  69. package/dist/commands/validate.d.ts +19 -0
  70. package/dist/commands/validate.d.ts.map +1 -1
  71. package/dist/commands/validate.js +26 -7
  72. package/dist/commands/validate.js.map +1 -1
  73. package/dist/constants.d.ts +1 -1
  74. package/dist/constants.js +1 -1
  75. package/dist/control-plane-tenant-provisioning.d.ts +24 -0
  76. package/dist/control-plane-tenant-provisioning.d.ts.map +1 -0
  77. package/dist/control-plane-tenant-provisioning.js +85 -0
  78. package/dist/control-plane-tenant-provisioning.js.map +1 -0
  79. package/dist/deploy-targets.d.ts +24 -0
  80. package/dist/deploy-targets.d.ts.map +1 -0
  81. package/dist/deploy-targets.js +38 -0
  82. package/dist/deploy-targets.js.map +1 -0
  83. package/dist/dev/account-selection.d.ts +16 -0
  84. package/dist/dev/account-selection.d.ts.map +1 -0
  85. package/dist/dev/account-selection.js +22 -0
  86. package/dist/dev/account-selection.js.map +1 -0
  87. package/dist/dev/browser-log-bridge.d.ts +32 -0
  88. package/dist/dev/browser-log-bridge.d.ts.map +1 -0
  89. package/dist/dev/browser-log-bridge.js +156 -0
  90. package/dist/dev/browser-log-bridge.js.map +1 -0
  91. package/dist/dev/compat-shim.d.ts +3 -3
  92. package/dist/dev/compat-shim.d.ts.map +1 -1
  93. package/dist/dev/compat-shim.js +29 -4
  94. package/dist/dev/compat-shim.js.map +1 -1
  95. package/dist/dev/component-selection.d.ts +25 -0
  96. package/dist/dev/component-selection.d.ts.map +1 -0
  97. package/dist/dev/component-selection.js +79 -0
  98. package/dist/dev/component-selection.js.map +1 -0
  99. package/dist/dev/console-capture.d.ts +18 -0
  100. package/dist/dev/console-capture.d.ts.map +1 -0
  101. package/dist/dev/console-capture.js +57 -0
  102. package/dist/dev/console-capture.js.map +1 -0
  103. package/dist/dev/event-bus.d.ts +1 -1
  104. package/dist/dev/event-bus.js +1 -1
  105. package/dist/dev/frontend-log-relay.d.ts +27 -0
  106. package/dist/dev/frontend-log-relay.d.ts.map +1 -0
  107. package/dist/dev/frontend-log-relay.js +102 -0
  108. package/dist/dev/frontend-log-relay.js.map +1 -0
  109. package/dist/dev/invoke.d.ts +3 -4
  110. package/dist/dev/invoke.d.ts.map +1 -1
  111. package/dist/dev/invoke.js +9 -4
  112. package/dist/dev/invoke.js.map +1 -1
  113. package/dist/dev/keyboard.d.ts +1 -0
  114. package/dist/dev/keyboard.d.ts.map +1 -1
  115. package/dist/dev/keyboard.js +9 -3
  116. package/dist/dev/keyboard.js.map +1 -1
  117. package/dist/dev/log-server.js +3 -0
  118. package/dist/dev/log-server.js.map +1 -1
  119. package/dist/dev/loopback-http-listener.d.ts +18 -0
  120. package/dist/dev/loopback-http-listener.d.ts.map +1 -0
  121. package/dist/dev/loopback-http-listener.js +91 -0
  122. package/dist/dev/loopback-http-listener.js.map +1 -0
  123. package/dist/dev/managed-session.d.ts +124 -0
  124. package/dist/dev/managed-session.d.ts.map +1 -0
  125. package/dist/dev/managed-session.js +597 -0
  126. package/dist/dev/managed-session.js.map +1 -0
  127. package/dist/dev/port-process.d.ts +21 -0
  128. package/dist/dev/port-process.d.ts.map +1 -0
  129. package/dist/dev/port-process.js +145 -0
  130. package/dist/dev/port-process.js.map +1 -0
  131. package/dist/dev/session-manager.d.ts +11 -1
  132. package/dist/dev/session-manager.d.ts.map +1 -1
  133. package/dist/dev/session-manager.js +65 -14
  134. package/dist/dev/session-manager.js.map +1 -1
  135. package/dist/dev/stream-event-format.d.ts +14 -0
  136. package/dist/dev/stream-event-format.d.ts.map +1 -0
  137. package/dist/dev/stream-event-format.js +262 -0
  138. package/dist/dev/stream-event-format.js.map +1 -0
  139. package/dist/dev/stream-filter.d.ts.map +1 -1
  140. package/dist/dev/stream-filter.js +16 -1
  141. package/dist/dev/stream-filter.js.map +1 -1
  142. package/dist/dev/stream-renderer.d.ts +4 -2
  143. package/dist/dev/stream-renderer.d.ts.map +1 -1
  144. package/dist/dev/stream-renderer.js +224 -84
  145. package/dist/dev/stream-renderer.js.map +1 -1
  146. package/dist/errors-registry.d.ts.map +1 -1
  147. package/dist/errors-registry.js +246 -0
  148. package/dist/errors-registry.js.map +1 -1
  149. package/dist/help.d.ts.map +1 -1
  150. package/dist/help.js +20 -3
  151. package/dist/help.js.map +1 -1
  152. package/dist/hubspot-app-logs.d.ts +38 -5
  153. package/dist/hubspot-app-logs.d.ts.map +1 -1
  154. package/dist/hubspot-app-logs.js +88 -17
  155. package/dist/hubspot-app-logs.js.map +1 -1
  156. package/dist/hubspot-auth.d.ts +19 -1
  157. package/dist/hubspot-auth.d.ts.map +1 -1
  158. package/dist/hubspot-auth.js +228 -83
  159. package/dist/hubspot-auth.js.map +1 -1
  160. package/dist/hubspot-developer-client.d.ts +87 -0
  161. package/dist/hubspot-developer-client.d.ts.map +1 -1
  162. package/dist/hubspot-developer-client.js +232 -1
  163. package/dist/hubspot-developer-client.js.map +1 -1
  164. package/dist/hubspot-project-archive.d.ts +21 -0
  165. package/dist/hubspot-project-archive.d.ts.map +1 -0
  166. package/dist/hubspot-project-archive.js +281 -0
  167. package/dist/hubspot-project-archive.js.map +1 -0
  168. package/dist/hubspot-project.d.ts +32 -0
  169. package/dist/hubspot-project.d.ts.map +1 -0
  170. package/dist/hubspot-project.js +269 -0
  171. package/dist/hubspot-project.js.map +1 -0
  172. package/dist/local-project-picker.js +1 -1
  173. package/dist/local-project-picker.js.map +1 -1
  174. package/dist/machine-artifact.d.ts +16 -0
  175. package/dist/machine-artifact.d.ts.map +1 -0
  176. package/dist/machine-artifact.js +18 -0
  177. package/dist/machine-artifact.js.map +1 -0
  178. package/dist/machine-id.d.ts +15 -0
  179. package/dist/machine-id.d.ts.map +1 -1
  180. package/dist/machine-id.js +53 -0
  181. package/dist/machine-id.js.map +1 -1
  182. package/dist/mutation-consent.d.ts +20 -0
  183. package/dist/mutation-consent.d.ts.map +1 -0
  184. package/dist/mutation-consent.js +9 -0
  185. package/dist/mutation-consent.js.map +1 -0
  186. package/dist/output-context.d.ts +3 -0
  187. package/dist/output-context.d.ts.map +1 -1
  188. package/dist/output-context.js +7 -1
  189. package/dist/output-context.js.map +1 -1
  190. package/dist/prompt.d.ts.map +1 -1
  191. package/dist/prompt.js +123 -48
  192. package/dist/prompt.js.map +1 -1
  193. package/dist/reporter/index.js +1 -1
  194. package/dist/reporter/index.js.map +1 -1
  195. package/dist/reporter/style.d.ts.map +1 -1
  196. package/dist/reporter/style.js +5 -0
  197. package/dist/reporter/style.js.map +1 -1
  198. package/dist/services/hubspot-dev-client.js +3 -0
  199. package/dist/services/hubspot-dev-client.js.map +1 -1
  200. package/dist/services/telemetry.d.ts +1 -2
  201. package/dist/services/telemetry.d.ts.map +1 -1
  202. package/dist/services/telemetry.js +1 -10
  203. package/dist/services/telemetry.js.map +1 -1
  204. package/dist/telemetry.d.ts.map +1 -1
  205. package/dist/telemetry.js +1 -7
  206. package/dist/telemetry.js.map +1 -1
  207. package/dist/tenant-data-plane-provisioning.d.ts +35 -0
  208. package/dist/tenant-data-plane-provisioning.d.ts.map +1 -0
  209. package/dist/tenant-data-plane-provisioning.js +38 -0
  210. package/dist/tenant-data-plane-provisioning.js.map +1 -0
  211. package/package.json +14 -12
@@ -7,7 +7,7 @@ import { controlPlaneAuthHeaders } from '../control-plane-fetch.js';
7
7
  import { loadCreateControlPlane } from '../control-plane-loader.js';
8
8
  import { executeCliHttp } from '../effect-http.js';
9
9
  import { emitHelp } from '../help.js';
10
- import { isInteractive, promptConfirm, promptSelect, promptText } from '../prompt.js';
10
+ import { isInteractive, promptConfirm, promptPassword, promptSelect, promptText, } from '../prompt.js';
11
11
  import { createReporter } from '../reporter/index.js';
12
12
  import { connectHelpText } from './help-command.js';
13
13
  async function hostedHttp(input) {
@@ -22,6 +22,87 @@ async function hostedHttp(input) {
22
22
  function isRecord(value) {
23
23
  return typeof value === 'object' && value !== null;
24
24
  }
25
+ /**
26
+ * A non-2xx from a hosted control-plane credential route, with the status and
27
+ * the CP's `{ error, message }` body preserved so callers can branch on
28
+ * authorization failures instead of collapsing everything into one retry
29
+ * message.
30
+ */
31
+ class ControlPlaneRequestError extends Error {
32
+ name = 'ControlPlaneRequestError';
33
+ status;
34
+ errorCode;
35
+ constructor(status, errorCode, message) {
36
+ super(message);
37
+ this.status = status;
38
+ this.errorCode = errorCode;
39
+ }
40
+ }
41
+ async function toControlPlaneRequestError(response, fallback) {
42
+ const body = await response.json().catch(() => undefined);
43
+ const errorCode = isRecord(body) && typeof body.error === 'string' ? body.error : undefined;
44
+ const message = isRecord(body) && typeof body.message === 'string' ? body.message : fallback;
45
+ return new ControlPlaneRequestError(response.status, errorCode, message);
46
+ }
47
+ /**
48
+ * Credential routes (`/v1/accounts/:id/{hubspot,cloudflare}/…`) are admin-class
49
+ * under the control plane's PAT policy, while ordinary reads only need `read`.
50
+ * A deploy/read-scope token (the dashboard's create form defaults to deploy)
51
+ * therefore sails through every preliminary check and 403s only here — an
52
+ * unfixable-by-retry failure that must be reported as a scope problem, not a
53
+ * generic exchange error.
54
+ */
55
+ function credentialAuthorizationDenied(input) {
56
+ if (input.error.status === 401) {
57
+ return inputValidationError({
58
+ argv: input.argv,
59
+ command: input.command,
60
+ json: input.json,
61
+ code: 'HSX_E_API_UNAUTHORIZED',
62
+ message: 'The control plane rejected the CLI credential.',
63
+ hint: 'Run `hs-x login` to refresh your CLI session, then retry.',
64
+ detail: input.error.message,
65
+ });
66
+ }
67
+ return inputValidationError({
68
+ argv: input.argv,
69
+ command: input.command,
70
+ json: input.json,
71
+ code: 'HSX_E_CREDENTIAL_SCOPE',
72
+ message: 'This CLI credential is not allowed to manage account credentials (admin scope required).',
73
+ hint: 'Run `hs-x login` — owners and admins get a full-access CLI session. If you logged in with a pasted API token, it needs admin scope (dashboard → API tokens); deploy and read tokens cannot connect credentials. Account members need an owner/admin to connect.',
74
+ detail: input.error.message,
75
+ });
76
+ }
77
+ function isCredentialAuthFailure(error) {
78
+ return (error instanceof ControlPlaneRequestError && (error.status === 401 || error.status === 403));
79
+ }
80
+ /**
81
+ * Probe whether the current CLI credential may manage credentials on
82
+ * `accountId` BEFORE any browser consent or credential prompting. Uses the
83
+ * credential-connection read (same admin PAT class as the connect/complete
84
+ * mutations); 404 just means nothing is connected yet, so only 401/403 count
85
+ * as a refusal. Network errors are swallowed — the real call will surface
86
+ * them with full context.
87
+ */
88
+ async function probeCredentialAuthorization(input) {
89
+ let response;
90
+ try {
91
+ response = await hostedHttp({
92
+ url: new URL(`/v1/accounts/${encodeURIComponent(input.accountId)}/${input.surface}`, input.controlPlaneUrl),
93
+ headers: {
94
+ accept: 'application/json',
95
+ ...(await controlPlaneAuthHeaders(input.userId)),
96
+ },
97
+ });
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ if (response.status !== 401 && response.status !== 403)
103
+ return undefined;
104
+ return await toControlPlaneRequestError(response, `Credential authorization probe returned HTTP ${response.status}.`);
105
+ }
25
106
  export async function connectCommand({ argv, cwd, json, }) {
26
107
  // Only a non-flag token is a subcommand — bare `connect --account-id x`
27
108
  // routes to the wizard with the flags passed through, not the help fallback.
@@ -133,6 +214,33 @@ async function connectHubspotCommand({ argv, cwd, json, }) {
133
214
  reporter.info(`Binding into HS-X account: ${active.displayName} (${active.id})`);
134
215
  }
135
216
  }
217
+ // Linked connects mutate an admin-class credential route. Fail here — before
218
+ // the PAK browser flow and prompts — when the CLI credential can't manage
219
+ // credentials at all (e.g. a deploy-scope API token), instead of letting the
220
+ // user generate and paste a PAK only to be refused at the connect call.
221
+ if (linked && !localControlPlane && !noControlPlane) {
222
+ const { activeSession } = await import('../account-store.js');
223
+ const store = await loadStore();
224
+ const probeAccountId = resolveFlag(argv, '--account-id') ??
225
+ activeSession(store)?.accountId?.trim() ??
226
+ store.defaultAccountId?.trim();
227
+ if (probeAccountId) {
228
+ const denied = await probeCredentialAuthorization({
229
+ controlPlaneUrl: resolveControlPlaneUrl(argv),
230
+ accountId: probeAccountId,
231
+ surface: 'hubspot',
232
+ userId: resolveFlag(argv, '--user-id') ?? process.env.HSX_USER_ID ?? 'local-cli-user',
233
+ });
234
+ if (denied) {
235
+ return credentialAuthorizationDenied({
236
+ argv,
237
+ command: 'connect hubspot',
238
+ json,
239
+ error: denied,
240
+ });
241
+ }
242
+ }
243
+ }
136
244
  let accountId = resolveFlag(argv, '--account-id');
137
245
  let developerAccountId = resolveFlag(argv, '--developer-account-id');
138
246
  let displayName = resolveFlag(argv, '--display-name');
@@ -235,7 +343,7 @@ async function connectHubspotCommand({ argv, cwd, json, }) {
235
343
  personalAccessKey = discoveredDefault.pak;
236
344
  }
237
345
  else {
238
- const answer = await promptText({
346
+ const answer = await promptPassword({
239
347
  message: 'HubSpot personal access key',
240
348
  validate: (v) => (v.length >= 10 ? undefined : 'PAK looks too short.'),
241
349
  });
@@ -434,12 +542,21 @@ async function connectHubspotCommand({ argv, cwd, json, }) {
434
542
  throw error;
435
543
  }
436
544
  }
437
- const connection = await requestHostedControlPlaneHubSpotConnect({
438
- accountId,
439
- request,
440
- controlPlaneUrl,
441
- userId: resolveFlag(argv, '--user-id') ?? process.env.HSX_USER_ID ?? 'local-cli-user',
442
- });
545
+ let connection;
546
+ try {
547
+ connection = await requestHostedControlPlaneHubSpotConnect({
548
+ accountId,
549
+ request,
550
+ controlPlaneUrl,
551
+ userId: resolveFlag(argv, '--user-id') ?? process.env.HSX_USER_ID ?? 'local-cli-user',
552
+ });
553
+ }
554
+ catch (error) {
555
+ if (isCredentialAuthFailure(error)) {
556
+ return credentialAuthorizationDenied({ argv, command: 'connect hubspot', json, error });
557
+ }
558
+ throw error;
559
+ }
443
560
  const result = {
444
561
  ok: true,
445
562
  command: 'connect hubspot',
@@ -758,12 +875,13 @@ async function runCloudflareOAuthFlowBody(input) {
758
875
  controlPlaneUrl,
759
876
  accountId: input.hsxAccountId,
760
877
  userId: resolveFlag(input.argv, '--user-id') ?? process.env.HSX_USER_ID ?? 'local-cli-user',
761
- body: {
878
+ body: Schema.decodeUnknownSync(schemas.CloudflareOAuthCompleteRequest)({
762
879
  code,
763
880
  codeVerifier: pkce.verifier,
764
881
  redirectUri: config.redirectUri,
882
+ ...(input.cloudflareAccountId ? { cloudflareAccountId: input.cloudflareAccountId } : {}),
765
883
  ...(input.displayName ? { displayName: input.displayName } : {}),
766
- },
884
+ }),
767
885
  });
768
886
  reporter
769
887
  .step('Cloudflare OAuth complete')
@@ -771,6 +889,20 @@ async function runCloudflareOAuthFlowBody(input) {
771
889
  return { kind: 'success-brokered', connection };
772
890
  }
773
891
  catch (error) {
892
+ // An authorization refusal (deploy/read-scope PAT, expired session) can
893
+ // never be fixed by re-running the OAuth flow — telling the user to
894
+ // retry sends them through browser consent in a loop.
895
+ if (isCredentialAuthFailure(error)) {
896
+ return {
897
+ kind: 'error',
898
+ result: credentialAuthorizationDenied({
899
+ argv: input.argv,
900
+ command: 'connect cloudflare',
901
+ json: input.json,
902
+ error,
903
+ }),
904
+ };
905
+ }
774
906
  const detail = error instanceof Error ? error.message : String(error);
775
907
  return {
776
908
  kind: 'error',
@@ -810,25 +942,48 @@ async function runCloudflareOAuthFlowBody(input) {
810
942
  }
811
943
  throw error;
812
944
  }
813
- let discoveredAccountId;
945
+ let selectedAccountId;
814
946
  try {
815
- // Resolve the account id via /accounts, NOT verifyCloudflareToken: OAuth
816
- // access tokens 400 on the API-token verify endpoint, which left the id
817
- // undefined and silently skipped persistence below (the credential is keyed
818
- // by account id). /accounts works with the granted account-read scope.
819
- const { resolveCloudflareAccountId } = await import('../cloudflare-auth.js');
820
- discoveredAccountId = await resolveCloudflareAccountId(tokens.accessToken);
947
+ // OAuth access tokens 400 on the API-token verify endpoint. Discover the
948
+ // first visible account when no selector was supplied; when the user did
949
+ // supply one, require Cloudflare to confirm access to that exact account.
950
+ const { selectCloudflareAccountId } = await import('../cloudflare-auth.js');
951
+ const cloudflareApiBaseUrl = process.env.HSX_CLOUDFLARE_API_BASE_URL;
952
+ selectedAccountId = await selectCloudflareAccountId({
953
+ token: tokens.accessToken,
954
+ ...(input.cloudflareAccountId ? { requestedAccountId: input.cloudflareAccountId } : {}),
955
+ ...(cloudflareApiBaseUrl ? { baseUrl: cloudflareApiBaseUrl } : {}),
956
+ });
821
957
  }
822
- catch {
823
- // account discovery is best-effort
958
+ catch (error) {
959
+ const { CloudflareAccountSelectionError } = await import('../cloudflare-auth.js');
960
+ if (error instanceof CloudflareAccountSelectionError) {
961
+ return {
962
+ kind: 'error',
963
+ result: inputValidationError({
964
+ argv: input.argv,
965
+ command: 'connect cloudflare',
966
+ json: input.json,
967
+ code: error.code,
968
+ message: error.message,
969
+ hint: error.reason === 'verification-failed'
970
+ ? 'Check the Cloudflare API connection and retry. The requested account was not selected or stored.'
971
+ : 'Choose a Cloudflare account this OAuth token can access, or omit --cloudflare-account-id to use the first visible account.',
972
+ detail: error.detail,
973
+ }),
974
+ };
975
+ }
976
+ if (input.cloudflareAccountId)
977
+ throw error;
978
+ // Discovery without an explicit selector remains best-effort.
824
979
  }
825
980
  // Persist the credential so a later `hs-x deploy` can reuse this connection
826
981
  // without re-authorizing. Cloudflare OAuth returns no refresh token, so the
827
982
  // access token IS what we store (with its expiry); deploy uses it directly
828
983
  // while valid and prompts a reconnect once it lapses.
829
- if (discoveredAccountId && (tokens.refreshToken || tokens.accessToken)) {
984
+ if (selectedAccountId && (tokens.refreshToken || tokens.accessToken)) {
830
985
  try {
831
- await saveCloudflareOAuthCredential(discoveredAccountId, {
986
+ await saveCloudflareOAuthCredential(selectedAccountId, {
832
987
  ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
833
988
  ...(tokens.accessToken
834
989
  ? { accessToken: tokens.accessToken, accessTokenExpiresAt: tokens.expiresAt }
@@ -842,11 +997,11 @@ async function runCloudflareOAuthFlowBody(input) {
842
997
  }
843
998
  reporter
844
999
  .step('Cloudflare OAuth complete')
845
- .ok(discoveredAccountId ? `account ${discoveredAccountId} (dev)` : 'token acquired (dev)');
1000
+ .ok(selectedAccountId ? `account ${selectedAccountId} (dev)` : 'token acquired (dev)');
846
1001
  return {
847
1002
  kind: 'success-local',
848
1003
  accessToken: tokens.accessToken,
849
- ...(discoveredAccountId ? { cloudflareAccountId: discoveredAccountId } : {}),
1004
+ ...(selectedAccountId ? { cloudflareAccountId: selectedAccountId } : {}),
850
1005
  };
851
1006
  }
852
1007
  async function postCloudflareOAuthComplete(input) {
@@ -861,8 +1016,7 @@ async function postCloudflareOAuthComplete(input) {
861
1016
  body: input.body,
862
1017
  });
863
1018
  if (!response.ok) {
864
- const text = await response.text().catch(() => '');
865
- throw new Error(`HTTP ${response.status}: ${text || response.statusText}`);
1019
+ throw await toControlPlaneRequestError(response, `Cloudflare OAuth completion failed with HTTP ${response.status}.`);
866
1020
  }
867
1021
  const raw = (await response.json());
868
1022
  return Schema.decodeUnknownSync(schemas.CloudflareConnection)(raw);
@@ -881,7 +1035,7 @@ async function promptHubspotBrowserFlow(argv) {
881
1035
  await openInBrowser(HUBSPOT_PAK_GENERATION_URL);
882
1036
  reporter.info('On the HubSpot page: generate a PAK and copy it.');
883
1037
  for (;;) {
884
- const pak = await promptText({
1038
+ const pak = await promptPassword({
885
1039
  message: 'Paste your HubSpot personal access key',
886
1040
  validate: (v) => (v.length >= 20 ? undefined : 'PAK looks too short.'),
887
1041
  });
@@ -966,6 +1120,78 @@ async function connectCloudflareCommand({ argv, json, }) {
966
1120
  const interactive = !json && isInteractive();
967
1121
  let cloudflareAccountId = resolveFlag(argv, '--cloudflare-account-id');
968
1122
  let displayName = resolveFlag(argv, '--display-name');
1123
+ // Linked OAuth mutates the named HS-X account after the user grants consent.
1124
+ // Fail before opening the browser when that account is only a stale local
1125
+ // record or the current user is not a member. Otherwise the user completes
1126
+ // Cloudflare consent only to receive a late 404 from the broker callback.
1127
+ if (linked && !localControlPlane) {
1128
+ const controlPlaneUrl = resolveControlPlaneUrl(argv);
1129
+ const userId = resolveFlag(argv, '--user-id') ?? process.env.HSX_USER_ID ?? 'local-cli-user';
1130
+ let response;
1131
+ try {
1132
+ response = await hostedHttp({
1133
+ url: new URL('/v1/accounts/me', controlPlaneUrl),
1134
+ headers: {
1135
+ accept: 'application/json',
1136
+ 'x-hsx-account-id': accountId,
1137
+ ...(await controlPlaneAuthHeaders(userId)),
1138
+ },
1139
+ });
1140
+ }
1141
+ catch (error) {
1142
+ const detail = error instanceof Error ? error.message : String(error);
1143
+ return inputValidationError({
1144
+ argv,
1145
+ command: 'connect cloudflare',
1146
+ json,
1147
+ code: 'HSX_E_API_NETWORK',
1148
+ message: 'Could not verify the target HS-X account before Cloudflare authorization.',
1149
+ hint: 'Check the control-plane URL and network connection, then retry.',
1150
+ detail,
1151
+ });
1152
+ }
1153
+ if (!response.ok) {
1154
+ const status = response.status;
1155
+ const code = status === 404
1156
+ ? 'HSX_E_API_NOT_FOUND'
1157
+ : status === 401 || status === 403
1158
+ ? 'HSX_E_API_UNAUTHORIZED'
1159
+ : status >= 500
1160
+ ? 'HSX_E_API_SERVER'
1161
+ : 'HSX_E_API_BAD_REQUEST';
1162
+ return inputValidationError({
1163
+ argv,
1164
+ command: 'connect cloudflare',
1165
+ json,
1166
+ code,
1167
+ message: status === 404
1168
+ ? `HS-X account ${accountId} does not exist on this control plane or is not available to the current user.`
1169
+ : `Could not verify HS-X account ${accountId} before Cloudflare authorization.`,
1170
+ hint: status === 404
1171
+ ? 'Run `hs-x api /v1/users/me/accounts` and choose an account returned by the control plane.'
1172
+ : 'Refresh your HS-X login and retry.',
1173
+ detail: `Account preflight returned HTTP ${status}.`,
1174
+ });
1175
+ }
1176
+ // Membership verified — now verify this credential may MANAGE credentials.
1177
+ // The account read above is read-class under PAT policy; the OAuth
1178
+ // completion is admin-class. Without this probe a deploy-scope token
1179
+ // passes preflight, burns a full browser consent, and only then 403s.
1180
+ const denied = await probeCredentialAuthorization({
1181
+ controlPlaneUrl,
1182
+ accountId,
1183
+ surface: 'cloudflare',
1184
+ userId,
1185
+ });
1186
+ if (denied) {
1187
+ return credentialAuthorizationDenied({
1188
+ argv,
1189
+ command: 'connect cloudflare',
1190
+ json,
1191
+ error: denied,
1192
+ });
1193
+ }
1194
+ }
969
1195
  // OAuth (authorization code + PKCE) is the ONLY connection method — owner
970
1196
  // decision 2026-06-11. No API-token paste path in the CLI or the dashboard:
971
1197
  // the consent screen grants exactly the scopes HS-X needs, and the
@@ -996,6 +1222,7 @@ async function connectCloudflareCommand({ argv, json, }) {
996
1222
  json,
997
1223
  hsxAccountId: accountId,
998
1224
  brokered: linked,
1225
+ ...(cloudflareAccountId ? { cloudflareAccountId } : {}),
999
1226
  ...(displayName ? { displayName } : {}),
1000
1227
  });
1001
1228
  if (oauthOutcome.kind === 'cancelled') {
@@ -1102,16 +1329,25 @@ async function connectCloudflareCommand({ argv, json, }) {
1102
1329
  };
1103
1330
  const controlPlaneUrl = resolveFlag(argv, '--control-plane-url') ?? process.env.HSX_CONTROL_PLANE_URL;
1104
1331
  const userId = resolveFlag(argv, '--user-id') ?? process.env.HSX_USER_ID ?? 'local-cli-user';
1105
- const connection = controlPlaneUrl
1106
- ? await requestHostedControlPlaneCloudflareConnect({
1107
- accountId,
1108
- request,
1109
- controlPlaneUrl,
1110
- userId,
1111
- })
1112
- : argv.includes('--local-control-plane')
1113
- ? await requestLocalControlPlaneCloudflareConnect({ accountId, request, userId })
1114
- : undefined;
1332
+ let connection;
1333
+ try {
1334
+ connection = controlPlaneUrl
1335
+ ? await requestHostedControlPlaneCloudflareConnect({
1336
+ accountId,
1337
+ request,
1338
+ controlPlaneUrl,
1339
+ userId,
1340
+ })
1341
+ : argv.includes('--local-control-plane')
1342
+ ? await requestLocalControlPlaneCloudflareConnect({ accountId, request, userId })
1343
+ : undefined;
1344
+ }
1345
+ catch (error) {
1346
+ if (isCredentialAuthFailure(error)) {
1347
+ return credentialAuthorizationDenied({ argv, command: 'connect cloudflare', json, error });
1348
+ }
1349
+ throw error;
1350
+ }
1115
1351
  if (!connection) {
1116
1352
  const result = {
1117
1353
  ok: true,
@@ -1198,7 +1434,7 @@ async function requestHostedControlPlaneHubSpotConnect({ accountId, request, con
1198
1434
  });
1199
1435
  const body = await response.json();
1200
1436
  if (!response.ok) {
1201
- throw new Error(isRecord(body) && typeof body.message === 'string'
1437
+ throw new ControlPlaneRequestError(response.status, isRecord(body) && typeof body.error === 'string' ? body.error : undefined, isRecord(body) && typeof body.message === 'string'
1202
1438
  ? body.message
1203
1439
  : `Control-plane HubSpot connect failed with status ${response.status}`);
1204
1440
  }
@@ -1234,7 +1470,7 @@ async function requestHostedControlPlaneCloudflareConnect({ accountId, request,
1234
1470
  });
1235
1471
  const body = await response.json();
1236
1472
  if (!response.ok) {
1237
- throw new Error(isRecord(body) && typeof body.message === 'string'
1473
+ throw new ControlPlaneRequestError(response.status, isRecord(body) && typeof body.error === 'string' ? body.error : undefined, isRecord(body) && typeof body.message === 'string'
1238
1474
  ? body.message
1239
1475
  : `Control-plane Cloudflare connect failed with status ${response.status}`);
1240
1476
  }