@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.
Files changed (298) 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/cloudflare-worker-versions.d.ts +184 -0
  32. package/dist/cloudflare-worker-versions.d.ts.map +1 -0
  33. package/dist/cloudflare-worker-versions.js +360 -0
  34. package/dist/cloudflare-worker-versions.js.map +1 -0
  35. package/dist/commands/account.d.ts +28 -13
  36. package/dist/commands/account.d.ts.map +1 -1
  37. package/dist/commands/account.js +219 -69
  38. package/dist/commands/account.js.map +1 -1
  39. package/dist/commands/api.d.ts +1 -1
  40. package/dist/commands/api.d.ts.map +1 -1
  41. package/dist/commands/api.js +6 -10
  42. package/dist/commands/api.js.map +1 -1
  43. package/dist/commands/completion.d.ts.map +1 -1
  44. package/dist/commands/completion.js +13 -7
  45. package/dist/commands/completion.js.map +1 -1
  46. package/dist/commands/connect.d.ts.map +1 -1
  47. package/dist/commands/connect.js +239 -125
  48. package/dist/commands/connect.js.map +1 -1
  49. package/dist/commands/control-plane-read.d.ts +72 -11
  50. package/dist/commands/control-plane-read.d.ts.map +1 -1
  51. package/dist/commands/control-plane-read.js +1035 -58
  52. package/dist/commands/control-plane-read.js.map +1 -1
  53. package/dist/commands/deploy-promote.d.ts +25 -3
  54. package/dist/commands/deploy-promote.d.ts.map +1 -1
  55. package/dist/commands/deploy-promote.js +314 -38
  56. package/dist/commands/deploy-promote.js.map +1 -1
  57. package/dist/commands/deploy.d.ts +67 -4
  58. package/dist/commands/deploy.d.ts.map +1 -1
  59. package/dist/commands/deploy.js +1333 -255
  60. package/dist/commands/deploy.js.map +1 -1
  61. package/dist/commands/dev.d.ts +5 -1
  62. package/dist/commands/dev.d.ts.map +1 -1
  63. package/dist/commands/dev.js +502 -107
  64. package/dist/commands/dev.js.map +1 -1
  65. package/dist/commands/doctor.d.ts +15 -1
  66. package/dist/commands/doctor.d.ts.map +1 -1
  67. package/dist/commands/doctor.js +131 -20
  68. package/dist/commands/doctor.js.map +1 -1
  69. package/dist/commands/flags.d.ts +108 -6
  70. package/dist/commands/flags.d.ts.map +1 -1
  71. package/dist/commands/flags.js +996 -75
  72. package/dist/commands/flags.js.map +1 -1
  73. package/dist/commands/help-command.d.ts +14 -0
  74. package/dist/commands/help-command.d.ts.map +1 -1
  75. package/dist/commands/help-command.js +144 -44
  76. package/dist/commands/help-command.js.map +1 -1
  77. package/dist/commands/history.js +14 -10
  78. package/dist/commands/history.js.map +1 -1
  79. package/dist/commands/init.d.ts +4 -2
  80. package/dist/commands/init.d.ts.map +1 -1
  81. package/dist/commands/init.js +69 -12
  82. package/dist/commands/init.js.map +1 -1
  83. package/dist/commands/link.d.ts +2 -3
  84. package/dist/commands/link.d.ts.map +1 -1
  85. package/dist/commands/link.js +201 -101
  86. package/dist/commands/link.js.map +1 -1
  87. package/dist/commands/login.d.ts +2 -2
  88. package/dist/commands/login.d.ts.map +1 -1
  89. package/dist/commands/login.js +22 -5
  90. package/dist/commands/login.js.map +1 -1
  91. package/dist/commands/migrate.d.ts +23 -3
  92. package/dist/commands/migrate.d.ts.map +1 -1
  93. package/dist/commands/migrate.js +500 -73
  94. package/dist/commands/migrate.js.map +1 -1
  95. package/dist/commands/rollback.d.ts +42 -2
  96. package/dist/commands/rollback.d.ts.map +1 -1
  97. package/dist/commands/rollback.js +422 -58
  98. package/dist/commands/rollback.js.map +1 -1
  99. package/dist/commands/secrets.d.ts +76 -3
  100. package/dist/commands/secrets.d.ts.map +1 -1
  101. package/dist/commands/secrets.js +328 -41
  102. package/dist/commands/secrets.js.map +1 -1
  103. package/dist/commands/status.d.ts +13 -2
  104. package/dist/commands/status.d.ts.map +1 -1
  105. package/dist/commands/status.js +154 -17
  106. package/dist/commands/status.js.map +1 -1
  107. package/dist/commands/unlink.d.ts +2 -2
  108. package/dist/commands/unlink.d.ts.map +1 -1
  109. package/dist/commands/unlink.js +2 -2
  110. package/dist/commands/unlink.js.map +1 -1
  111. package/dist/commands/update.d.ts.map +1 -1
  112. package/dist/commands/update.js +4 -3
  113. package/dist/commands/update.js.map +1 -1
  114. package/dist/commands/validate.d.ts +12 -3
  115. package/dist/commands/validate.d.ts.map +1 -1
  116. package/dist/commands/validate.js +70 -8
  117. package/dist/commands/validate.js.map +1 -1
  118. package/dist/constants.d.ts +1 -1
  119. package/dist/constants.js +1 -1
  120. package/dist/control-plane-fetch.d.ts.map +1 -1
  121. package/dist/control-plane-fetch.js +5 -4
  122. package/dist/control-plane-fetch.js.map +1 -1
  123. package/dist/deploy-revision-capture.d.ts +48 -0
  124. package/dist/deploy-revision-capture.d.ts.map +1 -0
  125. package/dist/deploy-revision-capture.js +125 -0
  126. package/dist/deploy-revision-capture.js.map +1 -0
  127. package/dist/dev/compat-shim.d.ts.map +1 -1
  128. package/dist/dev/compat-shim.js +10 -35
  129. package/dist/dev/compat-shim.js.map +1 -1
  130. package/dist/dev/event-bus.d.ts +5 -2
  131. package/dist/dev/event-bus.d.ts.map +1 -1
  132. package/dist/dev/event-bus.js +4 -1
  133. package/dist/dev/event-bus.js.map +1 -1
  134. package/dist/dev/hubspot-logs-poller.d.ts +35 -0
  135. package/dist/dev/hubspot-logs-poller.d.ts.map +1 -0
  136. package/dist/dev/hubspot-logs-poller.js +94 -0
  137. package/dist/dev/hubspot-logs-poller.js.map +1 -0
  138. package/dist/dev/invoke.d.ts +9 -1
  139. package/dist/dev/invoke.d.ts.map +1 -1
  140. package/dist/dev/invoke.js +69 -20
  141. package/dist/dev/invoke.js.map +1 -1
  142. package/dist/dev/keyboard.d.ts +31 -0
  143. package/dist/dev/keyboard.d.ts.map +1 -0
  144. package/dist/dev/keyboard.js +143 -0
  145. package/dist/dev/keyboard.js.map +1 -0
  146. package/dist/dev/log-server.d.ts +7 -0
  147. package/dist/dev/log-server.d.ts.map +1 -1
  148. package/dist/dev/log-server.js +68 -31
  149. package/dist/dev/log-server.js.map +1 -1
  150. package/dist/dev/migrated-card.d.ts +17 -0
  151. package/dist/dev/migrated-card.d.ts.map +1 -0
  152. package/dist/dev/migrated-card.js +114 -0
  153. package/dist/dev/migrated-card.js.map +1 -0
  154. package/dist/dev/stream-filter.d.ts +40 -0
  155. package/dist/dev/stream-filter.d.ts.map +1 -0
  156. package/dist/dev/stream-filter.js +93 -0
  157. package/dist/dev/stream-filter.js.map +1 -0
  158. package/dist/dev/stream-renderer.d.ts +34 -0
  159. package/dist/dev/stream-renderer.d.ts.map +1 -1
  160. package/dist/dev/stream-renderer.js +222 -8
  161. package/dist/dev/stream-renderer.js.map +1 -1
  162. package/dist/errors-registry.d.ts.map +1 -1
  163. package/dist/errors-registry.js +280 -36
  164. package/dist/errors-registry.js.map +1 -1
  165. package/dist/errors.d.ts.map +1 -1
  166. package/dist/errors.js.map +1 -1
  167. package/dist/help.d.ts.map +1 -1
  168. package/dist/help.js +8 -0
  169. package/dist/help.js.map +1 -1
  170. package/dist/hubspot-app-logs.d.ts +76 -0
  171. package/dist/hubspot-app-logs.d.ts.map +1 -0
  172. package/dist/hubspot-app-logs.js +323 -0
  173. package/dist/hubspot-app-logs.js.map +1 -0
  174. package/dist/hubspot-auth.d.ts +8 -2
  175. package/dist/hubspot-auth.d.ts.map +1 -1
  176. package/dist/hubspot-auth.js +36 -12
  177. package/dist/hubspot-auth.js.map +1 -1
  178. package/dist/hubspot-developer-client.js +119 -11
  179. package/dist/hubspot-developer-client.js.map +1 -1
  180. package/dist/index.d.ts +1 -0
  181. package/dist/index.d.ts.map +1 -1
  182. package/dist/index.js +1 -0
  183. package/dist/index.js.map +1 -1
  184. package/dist/init/templates.d.ts +10 -0
  185. package/dist/init/templates.d.ts.map +1 -1
  186. package/dist/init/templates.js +157 -14
  187. package/dist/init/templates.js.map +1 -1
  188. package/dist/link-migration-journal.d.ts +131 -0
  189. package/dist/link-migration-journal.d.ts.map +1 -0
  190. package/dist/link-migration-journal.js +395 -0
  191. package/dist/link-migration-journal.js.map +1 -0
  192. package/dist/link-preflight.d.ts +78 -0
  193. package/dist/link-preflight.d.ts.map +1 -0
  194. package/dist/link-preflight.js +231 -0
  195. package/dist/link-preflight.js.map +1 -0
  196. package/dist/local-project-picker.d.ts +34 -0
  197. package/dist/local-project-picker.d.ts.map +1 -0
  198. package/dist/local-project-picker.js +121 -0
  199. package/dist/local-project-picker.js.map +1 -0
  200. package/dist/logs-interactive.d.ts +37 -0
  201. package/dist/logs-interactive.d.ts.map +1 -0
  202. package/dist/logs-interactive.js +110 -0
  203. package/dist/logs-interactive.js.map +1 -0
  204. package/dist/output-context.d.ts +13 -0
  205. package/dist/output-context.d.ts.map +1 -0
  206. package/dist/output-context.js +46 -0
  207. package/dist/output-context.js.map +1 -0
  208. package/dist/project-context.d.ts +55 -0
  209. package/dist/project-context.d.ts.map +1 -0
  210. package/dist/project-context.js +115 -0
  211. package/dist/project-context.js.map +1 -0
  212. package/dist/project-picker.d.ts +65 -0
  213. package/dist/project-picker.d.ts.map +1 -0
  214. package/dist/project-picker.js +227 -0
  215. package/dist/project-picker.js.map +1 -0
  216. package/dist/prompt.d.ts +8 -0
  217. package/dist/prompt.d.ts.map +1 -1
  218. package/dist/prompt.js +81 -13
  219. package/dist/prompt.js.map +1 -1
  220. package/dist/reporter/human.d.ts +10 -2
  221. package/dist/reporter/human.d.ts.map +1 -1
  222. package/dist/reporter/human.js +82 -14
  223. package/dist/reporter/human.js.map +1 -1
  224. package/dist/reporter/index.d.ts.map +1 -1
  225. package/dist/reporter/index.js +7 -5
  226. package/dist/reporter/index.js.map +1 -1
  227. package/dist/reporter/json.d.ts +2 -1
  228. package/dist/reporter/json.d.ts.map +1 -1
  229. package/dist/reporter/json.js +10 -7
  230. package/dist/reporter/json.js.map +1 -1
  231. package/dist/reporter/style.d.ts +19 -0
  232. package/dist/reporter/style.d.ts.map +1 -1
  233. package/dist/reporter/style.js +126 -6
  234. package/dist/reporter/style.js.map +1 -1
  235. package/dist/reporter/types.d.ts +34 -4
  236. package/dist/reporter/types.d.ts.map +1 -1
  237. package/dist/reporter/types.js.map +1 -1
  238. package/dist/rollback-traffic.d.ts +85 -0
  239. package/dist/rollback-traffic.d.ts.map +1 -0
  240. package/dist/rollback-traffic.js +204 -0
  241. package/dist/rollback-traffic.js.map +1 -0
  242. package/dist/services/account-store.d.ts +3 -1
  243. package/dist/services/account-store.d.ts.map +1 -1
  244. package/dist/services/account-store.js +2 -1
  245. package/dist/services/account-store.js.map +1 -1
  246. package/dist/services/app-paths.d.ts.map +1 -1
  247. package/dist/services/app-paths.js +3 -1
  248. package/dist/services/app-paths.js.map +1 -1
  249. package/dist/services/cloudflare-auth.d.ts +1 -1
  250. package/dist/services/cloudflare-auth.d.ts.map +1 -1
  251. package/dist/services/cloudflare-auth.js +2 -2
  252. package/dist/services/cloudflare-auth.js.map +1 -1
  253. package/dist/services/cloudflare-kv.d.ts.map +1 -1
  254. package/dist/services/cloudflare-kv.js.map +1 -1
  255. package/dist/services/command-history.d.ts.map +1 -1
  256. package/dist/services/command-history.js.map +1 -1
  257. package/dist/services/config.d.ts.map +1 -1
  258. package/dist/services/config.js +1 -1
  259. package/dist/services/config.js.map +1 -1
  260. package/dist/services/control-plane.d.ts.map +1 -1
  261. package/dist/services/control-plane.js +3 -3
  262. package/dist/services/control-plane.js.map +1 -1
  263. package/dist/services/env-loader.d.ts.map +1 -1
  264. package/dist/services/env-loader.js +1 -3
  265. package/dist/services/env-loader.js.map +1 -1
  266. package/dist/services/http.d.ts.map +1 -1
  267. package/dist/services/http.js.map +1 -1
  268. package/dist/services/hubspot-dev-client.d.ts.map +1 -1
  269. package/dist/services/hubspot-dev-client.js +30 -0
  270. package/dist/services/hubspot-dev-client.js.map +1 -1
  271. package/dist/services/live.d.ts +2 -2
  272. package/dist/services/live.d.ts.map +1 -1
  273. package/dist/services/live.js +1 -1
  274. package/dist/services/live.js.map +1 -1
  275. package/dist/services/machine-id.d.ts.map +1 -1
  276. package/dist/services/machine-id.js.map +1 -1
  277. package/dist/services/reporter.d.ts +8 -5
  278. package/dist/services/reporter.d.ts.map +1 -1
  279. package/dist/services/reporter.js +6 -4
  280. package/dist/services/reporter.js.map +1 -1
  281. package/dist/services/tenant-state.d.ts.map +1 -1
  282. package/dist/services/tenant-state.js.map +1 -1
  283. package/dist/state-store.d.ts +4 -0
  284. package/dist/state-store.d.ts.map +1 -1
  285. package/dist/state-store.js +15 -0
  286. package/dist/state-store.js.map +1 -1
  287. package/dist/status-drilldown.d.ts +24 -0
  288. package/dist/status-drilldown.d.ts.map +1 -0
  289. package/dist/status-drilldown.js +50 -0
  290. package/dist/status-drilldown.js.map +1 -0
  291. package/dist/tenant-state.d.ts.map +1 -1
  292. package/dist/tenant-state.js +3 -2
  293. package/dist/tenant-state.js.map +1 -1
  294. package/dist/token-key-custody.d.ts +91 -0
  295. package/dist/token-key-custody.d.ts.map +1 -0
  296. package/dist/token-key-custody.js +375 -0
  297. package/dist/token-key-custody.js.map +1 -0
  298. package/package.json +13 -14
@@ -1,12 +1,19 @@
1
1
  import * as Command from '@effect/cli/Command';
2
+ import * as Options from '@effect/cli/Options';
2
3
  import { HttpClientRequest, Schema, schemas } from '@hs-x/types';
3
4
  import { Config, Effect, Option } from 'effect';
4
5
  import { isLinked } from '../account-store.js';
6
+ import { auditExplorer, filterAuditRows, promptAuditTimeRange, renderAuditDetail, } from '../audit-interactive.js';
7
+ import { checkpointDrilldown, } from '../checkpoint-interactive.js';
5
8
  import { CliError, cliError, exitWith } from '../cli-error.js';
6
9
  import { accountIdOption, controlPlaneUrlOption, jsonOption, projectIdOption, runHandler, userIdOption, } from '../cli/kit.js';
7
10
  import { DEFAULT_CONTROL_PLANE_URL } from '../config.js';
8
11
  import { executeCliHttpEffect } from '../effect-http.js';
12
+ import { fetchHubSpotLogsPayload, mergeLogRows, parseHubSpotLogSystemTypes, resolveHubSpotLogsSource, } from '../hubspot-app-logs.js';
13
+ import { promptInteractiveLogsSelection } from '../logs-interactive.js';
9
14
  import { readProjectBinding } from '../project-binding.js';
15
+ import { ProjectPickerCancelled, discoverProjects, resolveProjectSelection, } from '../project-picker.js';
16
+ import { isInteractive, promptSelect } from '../prompt.js';
10
17
  import { AccountStore } from '../services/account-store.js';
11
18
  import { ControlPlaneClient } from '../services/control-plane.js';
12
19
  import { Cwd } from '../services/cwd.js';
@@ -81,11 +88,283 @@ function renderCheckpoint(checkpoint) {
81
88
  lines.push(`Sources: ${sourceParts.join(', ')}`);
82
89
  return `${lines.join('\n')}\n`;
83
90
  }
91
+ export function renderCheckpointExemplar(failure) {
92
+ const exemplar = failure.exemplar;
93
+ const lines = [
94
+ `Capability: ${exemplar.capabilityId}`,
95
+ `Worker: ${exemplar.workerName}`,
96
+ `Deploy: ${exemplar.deployId}`,
97
+ `Outcome: ${exemplar.outcome}`,
98
+ `Observed: ${exemplar.observedAt}`,
99
+ `Duration: ${exemplar.durationMs}ms`,
100
+ `Portal: ${exemplar.portalId}`,
101
+ `Fingerprint: ${failure.fingerprint}`,
102
+ `Redacted input:\n${JSON.stringify(exemplar.redactedInput, null, 2)}`,
103
+ ];
104
+ if (exemplar.redactedOutput !== undefined) {
105
+ lines.push(`Redacted output:\n${JSON.stringify(exemplar.redactedOutput, null, 2)}`);
106
+ }
107
+ if (exemplar.redactedError !== undefined) {
108
+ lines.push(`Redacted error:\n${JSON.stringify(exemplar.redactedError, null, 2)}`);
109
+ }
110
+ return `${lines.join('\n')}\n`;
111
+ }
112
+ function clampInt(value, min, max) {
113
+ if (!Number.isFinite(value))
114
+ return min;
115
+ return Math.min(Math.max(Math.trunc(value), min), max);
116
+ }
117
+ function readString(row, key) {
118
+ const value = row[key];
119
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
120
+ }
121
+ function readNumber(row, key) {
122
+ const value = row[key];
123
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
124
+ }
125
+ function parseLogFilters(values) {
126
+ const filters = [];
127
+ for (const value of values) {
128
+ for (const rawPart of value.split(',')) {
129
+ const part = rawPart.trim();
130
+ if (!part)
131
+ continue;
132
+ const match = /^([a-zA-Z][a-zA-Z0-9_.-]*)(!=|>=|<=|=|>|<|~)(.+)$/.exec(part);
133
+ if (!match) {
134
+ return {
135
+ ok: false,
136
+ message: `Invalid log filter "${part}". Use key=value, key!=value, key~text, or status>=500.`,
137
+ };
138
+ }
139
+ const [, key, op, rawFilterValue] = match;
140
+ const filterValue = rawFilterValue?.trim() ?? '';
141
+ if (!key || filterValue.length === 0) {
142
+ return {
143
+ ok: false,
144
+ message: `Invalid log filter "${part}". Filters need a key and value.`,
145
+ };
146
+ }
147
+ filters.push({ key, op: op, value: filterValue });
148
+ }
149
+ }
150
+ return { ok: true, filters };
151
+ }
152
+ function parseSampleRate(value) {
153
+ if (value === undefined || value.trim() === '')
154
+ return { ok: true };
155
+ const rate = Number(value);
156
+ if (!Number.isFinite(rate) || rate <= 0 || rate > 1) {
157
+ return {
158
+ ok: false,
159
+ message: '--sample must be a number greater than 0 and less than or equal to 1.',
160
+ };
161
+ }
162
+ return { ok: true, rate };
163
+ }
164
+ function compareLogFilter(row, filter) {
165
+ const raw = row[filter.key];
166
+ if (raw === undefined || raw === null)
167
+ return false;
168
+ const rowText = String(raw);
169
+ const wantedText = filter.value;
170
+ if (filter.op === '~') {
171
+ return rowText.toLowerCase().includes(wantedText.toLowerCase());
172
+ }
173
+ const rowNumber = typeof raw === 'number' ? raw : Number(rowText);
174
+ const wantedNumber = Number(wantedText);
175
+ const canCompareNumber = Number.isFinite(rowNumber) && Number.isFinite(wantedNumber);
176
+ if (filter.op === '>=' || filter.op === '<=' || filter.op === '>' || filter.op === '<') {
177
+ if (!canCompareNumber)
178
+ return false;
179
+ if (filter.op === '>=')
180
+ return rowNumber >= wantedNumber;
181
+ if (filter.op === '<=')
182
+ return rowNumber <= wantedNumber;
183
+ if (filter.op === '>')
184
+ return rowNumber > wantedNumber;
185
+ return rowNumber < wantedNumber;
186
+ }
187
+ const matches = canCompareNumber
188
+ ? rowNumber === wantedNumber
189
+ : rowText.toLowerCase() === wantedText.toLowerCase();
190
+ return filter.op === '=' ? matches : !matches;
191
+ }
192
+ function stableLogRowKey(row) {
193
+ const invocationId = readString(row, 'invocationId');
194
+ if (invocationId)
195
+ return `invocation:${invocationId}`;
196
+ return [
197
+ readString(row, 'timestamp') ?? '',
198
+ readString(row, 'level') ?? '',
199
+ readString(row, 'message') ?? '',
200
+ readString(row, 'event') ?? '',
201
+ ].join('|');
202
+ }
203
+ function orderLogRowsAscending(rows) {
204
+ return [...rows].sort((a, b) => (readString(a, 'timestamp') ?? '').localeCompare(readString(b, 'timestamp') ?? ''));
205
+ }
206
+ function hashString(value) {
207
+ let hash = 2166136261;
208
+ for (let index = 0; index < value.length; index += 1) {
209
+ hash ^= value.charCodeAt(index);
210
+ hash = Math.imul(hash, 16777619);
211
+ }
212
+ return hash >>> 0;
213
+ }
214
+ function applyLogFilters(rows, filters, sampleRate) {
215
+ return rows.filter((row) => {
216
+ if (!filters.every((filter) => compareLogFilter(row, filter)))
217
+ return false;
218
+ if (sampleRate === undefined || sampleRate >= 1)
219
+ return true;
220
+ return hashString(stableLogRowKey(row)) / 0xffffffff <= sampleRate;
221
+ });
222
+ }
223
+ function asLogsPayload(body) {
224
+ if (!isRecord(body))
225
+ return { rows: [], error: 'invalid_response' };
226
+ const rows = Array.isArray(body.rows)
227
+ ? body.rows.filter(isRecord).map((row) => ({ origin: 'workers', ...row }))
228
+ : [];
229
+ return {
230
+ ...(typeof body.projectId === 'string' ? { projectId: body.projectId } : {}),
231
+ rows,
232
+ ...(typeof body.error === 'string' && body.error.length > 0 ? { error: body.error } : {}),
233
+ };
234
+ }
235
+ /** ok/warn/fail for a log row, from level, outcome, and HTTP status. */
236
+ function logRowStatus(row) {
237
+ const status = readNumber(row, 'status');
238
+ const outcome = (readString(row, 'outcome') ?? '').toLowerCase();
239
+ const level = (readString(row, 'level') ?? '').toLowerCase();
240
+ if (level === 'error' ||
241
+ outcome === 'error' ||
242
+ outcome === 'exception' ||
243
+ (status !== undefined && status >= 500)) {
244
+ return 'fail';
245
+ }
246
+ if (level === 'warn' || (status !== undefined && status >= 400))
247
+ return 'warn';
248
+ if (outcome === 'ok' || outcome === 'success' || (status !== undefined && status < 400)) {
249
+ return 'ok';
250
+ }
251
+ return undefined;
252
+ }
253
+ function originTag(row, showOrigin) {
254
+ if (!showOrigin)
255
+ return undefined;
256
+ return readString(row, 'origin') === 'hubspot' ? 'hs' : 'cf';
257
+ }
258
+ /** One railed row per invocation: glyph, cyan route, bright result, dim tail. */
259
+ function invocationReporterRow(row, showOrigin) {
260
+ const source = readString(row, 'source') ?? 'request';
261
+ const target = readString(row, 'target');
262
+ const method = readString(row, 'method');
263
+ const status = readNumber(row, 'status');
264
+ const outcome = readString(row, 'outcome');
265
+ const durationMs = readNumber(row, 'durationMs');
266
+ const invocationId = readString(row, 'invocationId') ?? 'unknown-invocation';
267
+ const rowStatus = logRowStatus(row);
268
+ const value = [method, status === undefined ? undefined : String(status)]
269
+ .filter((part) => part !== undefined)
270
+ .join(' ') ||
271
+ outcome ||
272
+ 'invocation';
273
+ const detail = [
274
+ readString(row, 'timestamp') ?? 'unknown-time',
275
+ durationMs === undefined ? undefined : `${durationMs}ms`,
276
+ originTag(row, showOrigin),
277
+ invocationId,
278
+ ]
279
+ .filter((part) => part !== undefined)
280
+ .join(' · ');
281
+ return {
282
+ key: target ? `${source}/${target}` : source,
283
+ value,
284
+ detail,
285
+ ...(rowStatus ? { status: rowStatus } : {}),
286
+ ...(rowStatus === 'fail' || rowStatus === 'warn'
287
+ ? { hint: `hs-x logs --trace ${invocationId}` }
288
+ : {}),
289
+ };
290
+ }
291
+ /** Raw tail/trace view: level-keyed rows; console output follows as a block. */
292
+ function tailReporterRow(row, showOrigin) {
293
+ const level = readString(row, 'level') ?? 'log';
294
+ const rowStatus = level === 'error' ? 'fail' : level === 'warn' ? 'warn' : undefined;
295
+ const detail = [
296
+ readString(row, 'timestamp') ?? 'unknown-time',
297
+ readString(row, 'event'),
298
+ readString(row, 'outcome'),
299
+ originTag(row, showOrigin),
300
+ ]
301
+ .filter((part) => part !== undefined && part.length > 0)
302
+ .join(' · ');
303
+ return {
304
+ key: level,
305
+ value: readString(row, 'message') ?? '',
306
+ detail,
307
+ ...(rowStatus ? { status: rowStatus } : {}),
308
+ };
309
+ }
310
+ /**
311
+ * Render a batch of log rows through the railed renderer. Invocations render
312
+ * as one aligned rail; tail/trace rows render individually so each row's
313
+ * captured console output can follow it verbatim.
314
+ */
315
+ function emitLogRows(reporter, rows, view, showOrigin) {
316
+ return Effect.gen(function* () {
317
+ if (rows.length === 0) {
318
+ yield* reporter.info(view === 'invocations'
319
+ ? 'No invocations found in this window.'
320
+ : 'No log rows found in this window.');
321
+ return;
322
+ }
323
+ if (view === 'invocations') {
324
+ yield* reporter.rows(rows.map((row) => invocationReporterRow(row, showOrigin)));
325
+ return;
326
+ }
327
+ for (const row of rows) {
328
+ yield* reporter.rows([tailReporterRow(row, showOrigin)]);
329
+ const consoleOutput = readString(row, 'consoleOutput');
330
+ if (consoleOutput)
331
+ yield* reporter.block(consoleOutput);
332
+ }
333
+ });
334
+ }
335
+ /** Outcome tally for the logs footer; only invocation rows carry outcomes. */
336
+ function logRowCounts(rows) {
337
+ const counts = { ok: 0, warn: 0, fail: 0 };
338
+ for (const row of rows) {
339
+ const status = logRowStatus(row);
340
+ if (status)
341
+ counts[status] += 1;
342
+ }
343
+ return counts;
344
+ }
84
345
  export function auditHelpText() {
85
346
  return `hs-x audit
86
347
 
348
+ Browse a bounded, redacted account audit feed. Bare hs-x audit opens an
349
+ interactive account, time-range, filter, and event-detail explorer. Scripts use
350
+ the explicit list form; JSON and non-TTY execution never prompt.
351
+
87
352
  Commands:
88
- hs-x audit list --control-plane-url <url> --account-id <id> [--user-id <id>] [--json]
353
+ hs-x audit
354
+ hs-x audit list [--account-id <id>] [--since <24h|7d|ISO>] [--limit <n>]
355
+
356
+ Filters:
357
+ --action <action> Exact audit action
358
+ --resource <type[/id]> Resource type or exact type/id
359
+ --actor <user-id> Actor user id
360
+ --outcome <outcome> success, failure, or in_progress
361
+ --since <time> Relative duration (30m, 24h, 7d) or ISO timestamp
362
+ --limit <n> Maximum rows, clamped to 1–200 (default 50)
363
+ --json Emit the bounded redacted result
364
+
365
+ Advanced:
366
+ --control-plane-url <url> Override control plane (HSX_CONTROL_PLANE_URL)
367
+ --user-id <id> Attribution for control-plane records
89
368
  `;
90
369
  }
91
370
  export function driftHelpText() {
@@ -100,21 +379,70 @@ from the .hs-x/project.json binding that link/deploy maintain.
100
379
  `;
101
380
  }
102
381
  export function checkpointHelpText() {
103
- return `hs-x logs
382
+ return `hs-x checkpoint
104
383
 
105
384
  Commands:
106
- hs-x logs --project <id> [--json]
385
+ hs-x checkpoint --project <id> [--json]
107
386
 
108
387
  Find project ids with hs-x list. --project is an alias of --project-id.
109
388
  Inside a deployed project directory the flag is optional — the id is read
110
- from the .hs-x/project.json binding that link/deploy maintain.
389
+ from the .hs-x/project.json binding that link/deploy maintain. In an
390
+ interactive terminal, recent failures can drill into matching Workers error
391
+ logs or their redacted representative invocation. Machine output never prompts.
111
392
 
112
393
  Advanced:
113
394
  --control-plane-url <url> Override control plane (HSX_CONTROL_PLANE_URL)
114
395
  --user-id <id> Attribution for control-plane records
396
+ `;
397
+ }
398
+ export function logsHelpText() {
399
+ return `hs-x logs
400
+
401
+ One timeline for everything your app does: Workers Logs rows from your
402
+ deployed runtime ([cf]) merged with HubSpot's app logs ([hs] — card and
403
+ extension renders, production frontend logger output, webhook deliveries,
404
+ API calls, OAuth authorizations). Each side degrades independently: Workers
405
+ Logs need a linked HS-X account, HubSpot logs need only a deployed app and
406
+ HubSpot CLI credentials.
407
+
408
+ Run bare hs-x logs in a terminal for a guided project, source, log-type,
409
+ and time-range picker. A project bound in the current directory is selected
410
+ by default. Flags skip the picker for scripts and repeatable workflows.
115
411
 
116
- Alias:
117
- hs-x checkpoint ...
412
+ Commands:
413
+ hs-x logs --project <id> [--account-id <id>] [--json]
414
+ hs-x logs --project <id> --trace <invocation-id>
415
+ hs-x logs --project <id> --follow --filter source=action,status>=500
416
+ hs-x logs --source hubspot --filter outcome=error
417
+
418
+ Find project ids with hs-x list. --project is an alias of --project-id.
419
+ Inside a deployed project directory the project flag is optional — the id is
420
+ read from the .hs-x/project.json binding that link/deploy maintain.
421
+
422
+ Options:
423
+ --account-id <id> HS-X account (default: the active account)
424
+ --source <name> all (default), workers, or hubspot
425
+ --type <name> HubSpot log type; repeat or comma-separate values
426
+ --errors-only Show errors only (server-side for HubSpot)
427
+ --limit <count> Rows to read, clamped to 1-200 (default: 50)
428
+ --since-minutes <count> Lookback window, clamped to 1-10080 (default: 15)
429
+ --trace <id> Read logger lines for one invocation, or HubSpot
430
+ rows matching a trace id (with console output)
431
+ --invocation <id> Alias of --trace
432
+ --raw Read raw tail events instead of invocation rows
433
+ --filter <expr> Filter rows; supports key=value, key!=value, key~text, status>=500
434
+ (rows carry origin=workers|hubspot and, for HubSpot
435
+ rows, systemType/errorType/portalId)
436
+ --sample <rate> Deterministically sample rows, 0 < rate <= 1
437
+ --follow Poll and append new rows until interrupted
438
+ --tail Alias of --follow
439
+ --interval-seconds <n> Follow polling interval, clamped to 1-60 (default: 3)
440
+
441
+ Advanced:
442
+ --app-id <id> HubSpot app id (default: the .hs-x/hubspot.json
443
+ binding recorded by deploy)
444
+ --control-plane-url <url> Override control plane (HSX_CONTROL_PLANE_URL)
445
+ --user-id <id> Attribution for control-plane records
118
446
  `;
119
447
  }
120
448
  export function listHelpText() {
@@ -218,10 +546,17 @@ function resolveFromOptionOrEnv(option, envKey) {
218
546
  return fromEnv.length > 0 ? fromEnv : undefined;
219
547
  });
220
548
  }
221
- /** Authenticated GET via the `ControlPlaneClient` (bearer session or x-hsx-user-id fallback). */
549
+ /**
550
+ * Authenticated GET via the `ControlPlaneClient`. The hosted control plane
551
+ * requires a real login session; only an explicitly overridden/local control
552
+ * plane may use the x-hsx-user-id development fallback.
553
+ */
222
554
  function cpGet(controlPlaneUrl, path, userId) {
223
555
  return Effect.gen(function* () {
224
556
  const client = yield* ControlPlaneClient;
557
+ if (new URL(controlPlaneUrl).origin === new URL(DEFAULT_CONTROL_PLANE_URL).origin) {
558
+ return yield* client.fetch({ controlPlaneUrl, path });
559
+ }
225
560
  const headers = yield* client.authHeaders(userId);
226
561
  const url = new URL(path, controlPlaneUrl);
227
562
  const request = HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers));
@@ -253,27 +588,183 @@ function resolveProjectId(opts) {
253
588
  return binding?.projectId;
254
589
  });
255
590
  }
591
+ /**
592
+ * Resolve an operations project without changing machine behavior. Explicit
593
+ * flag/env ids stay authoritative and never trigger catalog discovery. A local
594
+ * binding remains authoritative outside a TTY, but in a human TTY it is only
595
+ * the default hint for the account-scoped project picker.
596
+ */
597
+ export function resolveOperationProjectForRead(opts, input) {
598
+ return Effect.gen(function* () {
599
+ const authoritative = yield* resolveFromOptionOrEnv(opts.projectId, 'HSX_PROJECT_ID');
600
+ if (authoritative)
601
+ return { projectId: authoritative };
602
+ const cwd = yield* Cwd;
603
+ const binding = yield* Effect.promise(() => readProjectBinding(cwd));
604
+ if (!isInteractive()) {
605
+ return binding?.projectId ? { projectId: binding.projectId } : undefined;
606
+ }
607
+ const accountId = yield* resolveAccountId(opts, binding?.accountId);
608
+ if (!accountId) {
609
+ return binding?.projectId ? { projectId: binding.projectId } : undefined;
610
+ }
611
+ const controlPlaneUrl = yield* resolveControlPlaneUrl(opts.controlPlaneUrl);
612
+ const userId = yield* resolveUserId(opts);
613
+ const client = yield* ControlPlaneClient;
614
+ const controlPlane = {
615
+ request: (request) => Effect.runPromise(cpGet(controlPlaneUrl, request.path, userId).pipe(Effect.provideService(ControlPlaneClient, client))),
616
+ };
617
+ return yield* Effect.tryPromise({
618
+ try: async () => {
619
+ const projects = await discoverProjects({ accountId, controlPlane });
620
+ if (projects.length === 0)
621
+ throw new Error(input.emptyMessage);
622
+ const project = await resolveProjectSelection({
623
+ accountId,
624
+ projects,
625
+ ...(binding?.projectId ? { localProjectId: binding.projectId } : {}),
626
+ allowPrompt: true,
627
+ prompts: { select: promptSelect },
628
+ message: input.message,
629
+ });
630
+ return { projectId: project.id, accountId, project, projects };
631
+ },
632
+ catch: (cause) => cause,
633
+ }).pipe(Effect.catchAll((cause) => cause instanceof ProjectPickerCancelled
634
+ ? Effect.fail(exitWith(130))
635
+ : failBespoke(opts.json, input.command, cause instanceof Error ? cause.message : String(cause)).pipe(Effect.as(undefined))));
636
+ });
637
+ }
256
638
  const PROJECT_ID_HINT = 'pass --project <id> (find ids with `hs-x list`) or run inside a deployed project directory';
257
639
  // ─── audit ──────────────────────────────────────────────────────────────────
258
- export const auditCmd = Command.make('audit', cpReadOptions, (opts) => runHandler('audit list', { json: opts.json }, auditBody(opts)));
640
+ const auditOptions = {
641
+ ...cpReadOptions,
642
+ limit: Options.integer('limit').pipe(Options.withDefault(50)),
643
+ since: Options.text('since').pipe(Options.optional),
644
+ action: Options.text('action').pipe(Options.optional),
645
+ resource: Options.text('resource').pipe(Options.optional),
646
+ actor: Options.text('actor').pipe(Options.optional),
647
+ outcome: Options.text('outcome').pipe(Options.optional),
648
+ };
649
+ export const auditCmd = Command.make('audit', auditOptions, (opts) => runHandler('audit list', { json: opts.json }, auditBody(opts, { explorer: true })));
259
650
  // Same handler exposed as the `list` subcommand, so the dispatch can build the
260
651
  // legacy `audit list` shape: `Command.make('audit').withSubcommands([auditListCmd])`.
261
- export const auditListCmd = Command.make('list', cpReadOptions, (opts) => runHandler('audit list', { json: opts.json }, auditBody(opts)));
262
- function auditBody(opts) {
652
+ export const auditListCmd = Command.make('list', auditOptions, (opts) => runHandler('audit list', { json: opts.json }, auditBody(opts, { explorer: false })));
653
+ export function parseAuditSince(value, now = Date.now()) {
654
+ if (!value)
655
+ return { ok: true };
656
+ const relative = /^(\d+)(m|h|d)$/i.exec(value.trim());
657
+ if (relative) {
658
+ const amount = Number(relative[1]);
659
+ if (!Number.isSafeInteger(amount) || amount <= 0) {
660
+ return {
661
+ ok: false,
662
+ message: `Invalid --since "${value}". Use values like 30m, 24h, 7d, or an ISO timestamp.`,
663
+ };
664
+ }
665
+ const unit = relative[2]?.toLowerCase();
666
+ const multiplier = unit === 'd' ? 86_400_000 : unit === 'h' ? 3_600_000 : 60_000;
667
+ return { ok: true, since: new Date(now - amount * multiplier).toISOString() };
668
+ }
669
+ const timestamp = Date.parse(value);
670
+ if (!Number.isFinite(timestamp)) {
671
+ return {
672
+ ok: false,
673
+ message: `Invalid --since "${value}". Use values like 30m, 24h, 7d, or an ISO timestamp.`,
674
+ };
675
+ }
676
+ return { ok: true, since: new Date(timestamp).toISOString() };
677
+ }
678
+ function auditBody(opts, context) {
263
679
  return Effect.gen(function* () {
264
- const accountId = yield* resolveFromOptionOrEnv(opts.accountId, 'HSX_ACCOUNT_ID');
680
+ // Fall back to the stored default account (parity with `hs-x list`) so a
681
+ // logged-in user doesn't have to re-pass --account-id / HSX_ACCOUNT_ID.
682
+ let accountId = yield* resolveFromOptionOrEnv(opts.accountId, 'HSX_ACCOUNT_ID');
683
+ const accounts = yield* AccountStore;
684
+ const store = yield* accounts.load;
685
+ if (!accountId && context.explorer && !opts.json && isInteractive()) {
686
+ const candidates = Object.values(store.accounts).sort((left, right) => (left.displayName ?? left.id).localeCompare(right.displayName ?? right.id));
687
+ if (candidates.length === 1)
688
+ accountId = candidates[0]?.id;
689
+ else if (candidates.length > 1) {
690
+ accountId = yield* Effect.promise(() => promptSelect({
691
+ message: 'Which account audit should be inspected?',
692
+ options: candidates.map((account) => ({
693
+ value: account.id,
694
+ label: account.displayName ?? account.id,
695
+ description: account.id,
696
+ })),
697
+ ...(store.defaultAccountId ? { default: store.defaultAccountId } : {}),
698
+ }));
699
+ if (!accountId)
700
+ return yield* Effect.fail(exitWith(130));
701
+ }
702
+ }
703
+ accountId ??= store.defaultAccountId;
265
704
  if (!accountId) {
266
- return yield* failBespoke(opts.json, 'audit list', 'Audit list requires --account-id or HSX_ACCOUNT_ID.');
705
+ return yield* failBespoke(opts.json, 'audit list', 'Audit list requires an HS-X account: run `hs-x login`, or pass --account-id / HSX_ACCOUNT_ID.');
706
+ }
707
+ const parsedSince = parseAuditSince(Option.getOrUndefined(opts.since));
708
+ if (!parsedSince.ok)
709
+ return yield* failBespoke(opts.json, 'audit list', parsedSince.message);
710
+ let since = parsedSince.since;
711
+ if (context.explorer && !opts.json && isInteractive() && Option.isNone(opts.since)) {
712
+ const range = yield* Effect.promise(() => promptAuditTimeRange());
713
+ if (range === undefined)
714
+ return yield* Effect.fail(exitWith(130));
715
+ since = range === 'all' ? undefined : new Date(Date.now() - range * 60_000).toISOString();
716
+ }
717
+ const limit = clampInt(opts.limit, 1, 200);
718
+ const action = Option.getOrUndefined(opts.action);
719
+ const resource = Option.getOrUndefined(opts.resource);
720
+ const actor = Option.getOrUndefined(opts.actor);
721
+ const outcome = Option.getOrUndefined(opts.outcome);
722
+ if (outcome && !['success', 'failure', 'in_progress'].includes(outcome)) {
723
+ return yield* failBespoke(opts.json, 'audit list', `Invalid --outcome "${outcome}". Use success, failure, or in_progress.`);
267
724
  }
725
+ const filters = {
726
+ limit,
727
+ ...(since ? { since } : {}),
728
+ ...(action ? { action } : {}),
729
+ ...(resource ? { resource } : {}),
730
+ ...(actor ? { actor } : {}),
731
+ ...(outcome ? { outcome } : {}),
732
+ };
733
+ const params = new URLSearchParams({ limit: String(limit) });
734
+ if (filters.since)
735
+ params.set('since', filters.since);
736
+ if (filters.action)
737
+ params.set('action', filters.action);
738
+ if (filters.resource)
739
+ params.set('resource', filters.resource);
740
+ if (filters.actor)
741
+ params.set('actor', filters.actor);
742
+ if (filters.outcome)
743
+ params.set('outcome', filters.outcome);
268
744
  const controlPlaneUrl = yield* resolveControlPlaneUrl(opts.controlPlaneUrl);
269
745
  const userId = yield* resolveUserId(opts);
270
- const response = yield* cpGet(controlPlaneUrl, `/v1/accounts/${encodeURIComponent(accountId)}/audit`, userId);
746
+ const response = yield* cpGet(controlPlaneUrl, `/v1/accounts/${encodeURIComponent(accountId)}/audit?${params.toString()}`, userId);
271
747
  const body = yield* Effect.promise(() => response.json());
272
748
  if (!response.ok) {
273
749
  return yield* failBespoke(opts.json, 'audit list', responseMessage(body, response.status, 'Control-plane audit list failed'));
274
750
  }
275
- const rows = Schema.decodeUnknownSync(Schema.Array(schemas.AuditRow))(body);
276
- const result = { ok: true, command: 'audit list', mode: 'control-plane-audit', accountId, rows };
751
+ const decodedRows = Schema.decodeUnknownSync(Schema.Array(schemas.AuditRow))(body);
752
+ // Apply the same constraints client-side for compatibility with older
753
+ // control planes that ignore the new query parameters.
754
+ const rows = filterAuditRows(decodedRows, filters);
755
+ const result = {
756
+ ok: true,
757
+ command: 'audit list',
758
+ mode: 'control-plane-audit',
759
+ accountId,
760
+ limit,
761
+ ...(filters.since ? { since: filters.since } : {}),
762
+ ...(filters.action ? { action: filters.action } : {}),
763
+ ...(filters.resource ? { resource: filters.resource } : {}),
764
+ ...(filters.actor ? { actor: filters.actor } : {}),
765
+ ...(filters.outcome ? { outcome: filters.outcome } : {}),
766
+ rows,
767
+ };
277
768
  const reporter = yield* Reporter;
278
769
  if (opts.json) {
279
770
  yield* reporter.raw(`${JSON.stringify(result, null, 2)}\n`);
@@ -282,6 +773,15 @@ function auditBody(opts) {
282
773
  yield* reporter.header(accountId);
283
774
  yield* reporter.block(renderAuditRows(rows));
284
775
  yield* reporter.done(`${rows.length} row${rows.length === 1 ? '' : 's'}`);
776
+ if (context.explorer) {
777
+ yield* auditExplorer(rows, {
778
+ detail: (row) => Effect.gen(function* () {
779
+ yield* reporter.header(`${row.action} audit event`);
780
+ yield* reporter.block(renderAuditDetail(row));
781
+ yield* reporter.done('Audit event');
782
+ }),
783
+ });
784
+ }
285
785
  });
286
786
  }
287
787
  // ─── drift ────────────────────────────────────────────────────────────────────
@@ -298,7 +798,12 @@ function driftBody(opts) {
298
798
  hint: UNLINKED_DRIFT_HINT,
299
799
  });
300
800
  }
301
- const projectId = yield* resolveProjectId(opts);
801
+ const selection = yield* resolveOperationProjectForRead(opts, {
802
+ command: 'drift',
803
+ message: 'Which project do you want to inspect for drift?',
804
+ emptyMessage: 'No projects yet. Run `hs-x deploy` from a project to create one.',
805
+ });
806
+ const projectId = selection?.projectId;
302
807
  if (!projectId) {
303
808
  return yield* failBespoke(opts.json, 'drift', `Drift status needs a project: ${PROJECT_ID_HINT}.`);
304
809
  }
@@ -344,17 +849,13 @@ function unlinkedRequiresLinkEff(input) {
344
849
  }
345
850
  // ─── list (projects) ──────────────────────────────────────────────────────────
346
851
  export const listCmd = Command.make('list', cpReadOptions, (opts) => runHandler('list', { json: opts.json }, listProjectsBody(opts)));
347
- function renderProjects(projects) {
348
- if (projects.length === 0) {
349
- return 'No projects yet. Run `hs-x deploy` from a project to create one.\n';
350
- }
351
- const widestId = Math.max(...projects.map((p) => p.id.length));
852
+ function projectRows(projects) {
352
853
  const widestName = Math.max(...projects.map((p) => p.displayName.length));
353
- const lines = projects.map((p) => {
354
- const lastDeploy = p.lastDeployAt ? `last deploy ${p.lastDeployAt}` : 'no recorded deploys';
355
- return `${p.id.padEnd(widestId)} ${p.displayName.padEnd(widestName)} ${lastDeploy}`;
356
- });
357
- return `${lines.join('\n')}\n`;
854
+ return projects.map((p) => ({
855
+ key: p.id,
856
+ value: p.displayName.padEnd(widestName),
857
+ detail: p.lastDeployAt ? `last deploy ${p.lastDeployAt}` : 'no recorded deploys',
858
+ }));
358
859
  }
359
860
  function listProjectsBody(opts) {
360
861
  return Effect.gen(function* () {
@@ -375,14 +876,25 @@ function listProjectsBody(opts) {
375
876
  return yield* failBespoke(opts.json, 'list', responseMessage(body, response.status, 'Control-plane project list failed'));
376
877
  }
377
878
  const { projects } = Schema.decodeUnknownSync(schemas.ListProjectsResponse)(body);
378
- const result = { ok: true, command: 'list', mode: 'control-plane-projects', accountId, projects };
879
+ const result = {
880
+ ok: true,
881
+ command: 'list',
882
+ mode: 'control-plane-projects',
883
+ accountId,
884
+ projects,
885
+ };
379
886
  const reporter = yield* Reporter;
380
887
  if (opts.json) {
381
888
  yield* reporter.raw(`${JSON.stringify(result, null, 2)}\n`);
382
889
  return;
383
890
  }
384
891
  yield* reporter.header(accountId);
385
- yield* reporter.block(renderProjects(projects));
892
+ if (projects.length === 0) {
893
+ yield* reporter.info('No projects yet. Run `hs-x deploy` from a project to create one.');
894
+ }
895
+ else {
896
+ yield* reporter.rows(projectRows(projects));
897
+ }
386
898
  if (projects.length > 0) {
387
899
  yield* reporter.info('Use an id with project-scoped commands: `hs-x logs --project <id>`.');
388
900
  }
@@ -400,7 +912,7 @@ function listProjectsBody(opts) {
400
912
  * just means the CP has nothing for it — a project with no drift snapshot is
401
913
  * healthy, not broken.
402
914
  */
403
- export function fetchRemoteProjectStatus(opts, projectId) {
915
+ export function fetchRemoteProjectStatus(opts, projectId, prefetchedProjects) {
404
916
  return Effect.gen(function* () {
405
917
  const controlPlaneUrl = yield* resolveControlPlaneUrl(opts.controlPlaneUrl);
406
918
  const userId = yield* resolveUserId(opts);
@@ -413,34 +925,30 @@ export function fetchRemoteProjectStatus(opts, projectId) {
413
925
  return decoded._tag === 'Right' ? decoded.right : undefined;
414
926
  });
415
927
  const encodedProject = encodeURIComponent(projectId);
416
- const [routing, drift, checkpoint] = yield* Effect.all([
928
+ const [routing, drift, checkpoint, revisions] = yield* Effect.all([
417
929
  fetchSection(`/v1/projects/${encodedProject}/routing`, schemas.ProjectRoutingRead),
418
930
  fetchSection(`/v1/projects/${encodedProject}/drift`, schemas.DriftRead),
419
931
  fetchSection(`/v1/projects/${encodedProject}/checkpoint`, schemas.CheckpointPanelRead),
420
- ], { concurrency: 3 });
421
- let accountId = yield* resolveFromOptionOrEnv(opts.accountId, 'HSX_ACCOUNT_ID');
422
- if (!accountId) {
423
- const accounts = yield* AccountStore;
424
- const store = yield* accounts.load;
425
- accountId = store.defaultAccountId;
426
- }
427
- const projectList = accountId
428
- ? yield* fetchSection(`/v1/accounts/${encodeURIComponent(accountId)}/projects`, schemas.ListProjectsResponse)
429
- : undefined;
430
- const project = projectList?.projects.find((candidate) => candidate.id === projectId);
932
+ fetchSection(`/v1/projects/${encodedProject}/revisions`, schemas.ProjectRevisionRead),
933
+ ], { concurrency: 4 });
934
+ const project = prefetchedProjects?.find((candidate) => candidate.id === projectId);
935
+ const catalogAccountId = prefetchedProjects?.[0]?.accountId;
431
936
  return {
432
937
  ...(project ? { project } : {}),
433
938
  ...(routing ? { routing } : {}),
434
939
  ...(drift ? { drift } : {}),
435
940
  ...(checkpoint ? { checkpoint } : {}),
436
- ...(projectList && !project && accountId ? { missingOnAccountId: accountId } : {}),
941
+ ...(revisions ? { revisions } : {}),
942
+ ...(prefetchedProjects && !project && catalogAccountId
943
+ ? { missingOnAccountId: catalogAccountId }
944
+ : {}),
437
945
  };
438
946
  });
439
947
  }
440
948
  /** `hs-x status --project <id>` — the remote project view as a command body. */
441
- export function remoteStatusBody(opts, projectId) {
949
+ export function remoteStatusBody(opts, projectId, prefetchedProjects) {
442
950
  return Effect.gen(function* () {
443
- const remote = yield* fetchRemoteProjectStatus(opts, projectId);
951
+ const remote = yield* fetchRemoteProjectStatus(opts, projectId, prefetchedProjects);
444
952
  if (remote.missingOnAccountId && !remote.routing && !remote.drift && !remote.checkpoint) {
445
953
  return yield* failBespoke(opts.json, 'status', `No project ${projectId} on account ${remote.missingOnAccountId}. Find ids with \`hs-x list\`.`);
446
954
  }
@@ -465,9 +973,22 @@ export function remoteStatusBody(opts, projectId) {
465
973
  });
466
974
  }
467
975
  export function renderRemoteStatus(input) {
976
+ // Recorded revisions are the authoritative deploy history — a project can have
977
+ // many recorded deploys with nothing promoted, so `lastDeployAt` (from the
978
+ // project list, populated on promotion) is not a reliable "last deploy".
979
+ const recorded = input.revisions?.revisions ?? [];
980
+ const lastRecorded = recorded.length > 0 ? recorded[recorded.length - 1] : undefined;
981
+ const lastDeploy = input.project?.lastDeployAt ??
982
+ (lastRecorded ? `${lastRecorded.recordedAt} (${lastRecorded.deployId})` : undefined) ??
983
+ 'none recorded';
468
984
  const lines = [
469
985
  `Project: ${input.projectId}${input.project ? ` (${input.project.displayName})` : ''}`,
470
- `Last deploy: ${input.project?.lastDeployAt ?? 'none recorded'}`,
986
+ `Last deploy: ${lastDeploy}`,
987
+ ...(recorded.length > 0
988
+ ? [
989
+ `Deploys recorded: ${recorded.length} (active ${input.revisions?.activeDeployId ?? 'none'})`,
990
+ ]
991
+ : []),
471
992
  ];
472
993
  if (input.routing && input.routing.routes.length > 0) {
473
994
  const widest = Math.max(...input.routing.routes.map((route) => route.environment.length));
@@ -491,30 +1012,30 @@ export function renderRemoteStatus(input) {
491
1012
  }
492
1013
  return `${lines.join('\n')}\n`;
493
1014
  }
494
- // ─── checkpoint / logs ────────────────────────────────────────────────────────
495
- /** Single Command value reused for both `checkpoint` and the `logs` alias. */
496
- function makeCheckpointCmd(name) {
497
- return Command.make(name, cpReadOptions, (opts) => runHandler(name, { json: opts.json }, checkpointBody(name, opts)));
498
- }
499
- export const checkpointCmd = makeCheckpointCmd('checkpoint');
500
- export const logsCmd = makeCheckpointCmd('logs');
501
- function checkpointBody(commandName, opts) {
1015
+ // ─── checkpoint ───────────────────────────────────────────────────────────────
1016
+ export const checkpointCmd = Command.make('checkpoint', cpReadOptions, (opts) => runHandler('checkpoint', { json: opts.json }, checkpointBody(opts)));
1017
+ function checkpointBody(opts) {
502
1018
  return Effect.gen(function* () {
503
- const projectId = yield* resolveProjectId(opts);
1019
+ const selection = yield* resolveOperationProjectForRead(opts, {
1020
+ command: 'checkpoint',
1021
+ message: 'Which project do you want to inspect?',
1022
+ emptyMessage: 'No projects yet. Run `hs-x deploy` from a project to create one.',
1023
+ });
1024
+ const projectId = selection?.projectId;
504
1025
  if (!projectId) {
505
- return yield* failBespoke(opts.json, commandName, `${commandName === 'logs' ? 'Logs need' : 'Checkpoint status needs'} a project: ${PROJECT_ID_HINT}.`);
1026
+ return yield* failBespoke(opts.json, 'checkpoint', `Checkpoint status needs a project: ${PROJECT_ID_HINT}.`);
506
1027
  }
507
1028
  const controlPlaneUrl = yield* resolveControlPlaneUrl(opts.controlPlaneUrl);
508
1029
  const userId = yield* resolveUserId(opts);
509
1030
  const response = yield* cpGet(controlPlaneUrl, `/v1/projects/${encodeURIComponent(projectId)}/checkpoint`, userId);
510
1031
  const body = yield* Effect.promise(() => response.json());
511
1032
  if (!response.ok) {
512
- return yield* failBespoke(opts.json, commandName, responseMessage(body, response.status, 'Control-plane checkpoint status failed'));
1033
+ return yield* failBespoke(opts.json, 'checkpoint', responseMessage(body, response.status, 'Control-plane checkpoint status failed'));
513
1034
  }
514
1035
  const checkpoint = Schema.decodeUnknownSync(schemas.CheckpointPanelRead)(body);
515
1036
  const result = {
516
1037
  ok: true,
517
- command: commandName,
1038
+ command: 'checkpoint',
518
1039
  mode: 'control-plane-checkpoint',
519
1040
  projectId,
520
1041
  checkpoint,
@@ -527,8 +1048,459 @@ function checkpointBody(commandName, opts) {
527
1048
  yield* reporter.header(projectId);
528
1049
  yield* reporter.block(renderCheckpoint(checkpoint));
529
1050
  yield* reporter.done();
1051
+ yield* checkpointDrilldown(checkpoint, {
1052
+ actions: {
1053
+ logs: (failure) => logsBodyForCheckpoint(opts, selection ?? { projectId }, checkpoint, failure),
1054
+ exemplar: (failure) => Effect.gen(function* () {
1055
+ yield* reporter.header(`${failure.errorClass} exemplar`);
1056
+ yield* reporter.block(renderCheckpointExemplar(failure));
1057
+ yield* reporter.done('Representative invocation');
1058
+ }),
1059
+ },
1060
+ });
1061
+ });
1062
+ }
1063
+ // ─── logs ─────────────────────────────────────────────────────────────────────
1064
+ const logsOptions = {
1065
+ ...cpReadOptions,
1066
+ limit: Options.integer('limit').pipe(Options.withDefault(50)),
1067
+ sinceMinutes: Options.integer('since-minutes').pipe(Options.withDefault(15)),
1068
+ trace: Options.text('trace').pipe(Options.withAlias('invocation'), Options.optional),
1069
+ raw: Options.boolean('raw').pipe(Options.withDefault(false)),
1070
+ filter: Options.text('filter').pipe(Options.repeated),
1071
+ sample: Options.text('sample').pipe(Options.optional),
1072
+ follow: Options.boolean('follow').pipe(Options.withAlias('tail'), Options.withDefault(false)),
1073
+ intervalSeconds: Options.integer('interval-seconds').pipe(Options.withDefault(3)),
1074
+ source: Options.text('source').pipe(Options.optional),
1075
+ appId: Options.text('app-id').pipe(Options.optional),
1076
+ type: Options.text('type').pipe(Options.repeated),
1077
+ errorsOnly: Options.boolean('errors-only').pipe(Options.withDefault(false)),
1078
+ };
1079
+ const LOG_SELECTOR_FLAGS = [
1080
+ '--source',
1081
+ '--project',
1082
+ '--project-id',
1083
+ '--app-id',
1084
+ '--type',
1085
+ '--since-minutes',
1086
+ '--limit',
1087
+ '--trace',
1088
+ '--invocation',
1089
+ '--raw',
1090
+ '--filter',
1091
+ '--sample',
1092
+ '--follow',
1093
+ '--tail',
1094
+ '--errors-only',
1095
+ ];
1096
+ function hasExplicitLogSelector(argv = process.argv.slice(2)) {
1097
+ return argv.some((arg) => LOG_SELECTOR_FLAGS.some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
1098
+ }
1099
+ function isErrorLogRow(row) {
1100
+ if (row.level === 'error' || row.errorType)
1101
+ return true;
1102
+ if (typeof row.status === 'number' && row.status >= 400)
1103
+ return true;
1104
+ return typeof row.outcome === 'string' && row.outcome !== 'ok';
1105
+ }
1106
+ function parseLogSource(value) {
1107
+ const normalized = (value ?? 'all').toLowerCase();
1108
+ if (normalized === 'all' || normalized === 'merged') {
1109
+ return { ok: true, wantWorkers: true, wantHubspot: true };
1110
+ }
1111
+ if (normalized === 'workers' || normalized === 'cf' || normalized === 'cloudflare') {
1112
+ return { ok: true, wantWorkers: true, wantHubspot: false };
1113
+ }
1114
+ if (normalized === 'hubspot' || normalized === 'hs') {
1115
+ return { ok: true, wantWorkers: false, wantHubspot: true };
1116
+ }
1117
+ return { ok: false, message: `Invalid --source "${value}". Use all, workers, or hubspot.` };
1118
+ }
1119
+ export const logsCmd = Command.make('logs', logsOptions, (opts) => runHandler('logs', { json: opts.json }, logsBody(opts)));
1120
+ function resolveAccountId(opts, localAccountId) {
1121
+ return Effect.gen(function* () {
1122
+ const explicit = yield* resolveFromOptionOrEnv(opts.accountId, 'HSX_ACCOUNT_ID');
1123
+ if (explicit)
1124
+ return explicit;
1125
+ if (localAccountId)
1126
+ return localAccountId;
1127
+ const accounts = yield* AccountStore;
1128
+ const store = yield* accounts.load;
1129
+ return store.defaultAccountId;
530
1130
  });
531
1131
  }
1132
+ /** Actionable remediation for the known Workers Logs error slugs. */
1133
+ function workersErrorHint(error) {
1134
+ if (error === 'cloudflare_credential_invalid') {
1135
+ return 'the stored Cloudflare credential was rejected (expired or revoked) — reconnect with `hs-x connect cloudflare`';
1136
+ }
1137
+ if (error === 'observability_permission_missing') {
1138
+ return 'the stored Cloudflare token cannot read Workers Observability — reconnect with `hs-x connect cloudflare`, or use an API token with the Workers Observability read permission';
1139
+ }
1140
+ if (error === 'cloudflare_not_connected') {
1141
+ return 'no Cloudflare account is connected — run `hs-x connect cloudflare`';
1142
+ }
1143
+ return undefined;
1144
+ }
1145
+ function withWorkersErrorHint(error) {
1146
+ const hint = workersErrorHint(error);
1147
+ return hint ? `${error} (${hint})` : error;
1148
+ }
1149
+ function logsBody(opts, context = {}) {
1150
+ return Effect.gen(function* () {
1151
+ const cwd = yield* Cwd;
1152
+ const localProjectBinding = yield* Effect.promise(() => readProjectBinding(cwd));
1153
+ const requestedSource = parseLogSource(Option.getOrUndefined(opts.source));
1154
+ const shouldResolveWorkersProject = requestedSource.ok && requestedSource.wantWorkers;
1155
+ const projectSelection = shouldResolveWorkersProject
1156
+ ? (context.projectSelection ??
1157
+ (yield* resolveOperationProjectForRead(opts, {
1158
+ command: 'logs',
1159
+ message: 'Which project do you want logs for?',
1160
+ emptyMessage: 'No projects yet. Run `hs-x deploy` from a project to create one.',
1161
+ })))
1162
+ : undefined;
1163
+ const currentProjectId = projectSelection?.projectId ??
1164
+ (yield* resolveProjectId(opts)) ??
1165
+ localProjectBinding?.projectId;
1166
+ const accountId = projectSelection?.accountId ??
1167
+ (yield* resolveAccountId(opts, localProjectBinding?.accountId));
1168
+ const controlPlaneUrl = yield* resolveControlPlaneUrl(opts.controlPlaneUrl);
1169
+ const userId = yield* resolveUserId(opts);
1170
+ const shouldPrompt = !opts.json &&
1171
+ isInteractive() &&
1172
+ (context.forceInteractiveSetup === true || !hasExplicitLogSelector());
1173
+ let interactiveSelection = undefined;
1174
+ if (shouldPrompt) {
1175
+ if (!accountId) {
1176
+ return yield* failBespoke(false, 'logs', 'Logs require an HS-X account: run `hs-x login`, or pass --account-id / HSX_ACCOUNT_ID.');
1177
+ }
1178
+ if (!currentProjectId) {
1179
+ return yield* failBespoke(false, 'logs', `Logs need a project: ${PROJECT_ID_HINT}.`);
1180
+ }
1181
+ interactiveSelection = yield* Effect.promise(() => promptInteractiveLogsSelection({
1182
+ projects: projectSelection?.projects ?? [],
1183
+ selectedProjectId: currentProjectId,
1184
+ ...(localProjectBinding?.projectId
1185
+ ? { hubspotProjectId: localProjectBinding.projectId }
1186
+ : {}),
1187
+ }));
1188
+ if (!interactiveSelection) {
1189
+ return yield* Effect.fail(exitWith(130));
1190
+ }
1191
+ }
1192
+ const projectId = interactiveSelection?.projectId ?? currentProjectId;
1193
+ const parsedFilters = parseLogFilters(opts.filter);
1194
+ if (!parsedFilters.ok) {
1195
+ return yield* failBespoke(opts.json, 'logs', parsedFilters.message);
1196
+ }
1197
+ const parsedSample = parseSampleRate(Option.getOrUndefined(opts.sample));
1198
+ if (!parsedSample.ok) {
1199
+ return yield* failBespoke(opts.json, 'logs', parsedSample.message);
1200
+ }
1201
+ const follow = interactiveSelection?.follow ?? opts.follow;
1202
+ const errorsOnly = interactiveSelection?.errorsOnly ?? opts.errorsOnly;
1203
+ const parsedSystemTypes = interactiveSelection?.systemTypes
1204
+ ? { ok: true, systemTypes: interactiveSelection.systemTypes }
1205
+ : parseHubSpotLogSystemTypes(opts.type);
1206
+ if (!parsedSystemTypes.ok) {
1207
+ return yield* failBespoke(opts.json, 'logs', parsedSystemTypes.message);
1208
+ }
1209
+ if (follow && opts.json) {
1210
+ return yield* failBespoke(opts.json, 'logs', '`hs-x logs --follow`/`--tail` streams terminal output. Omit --json for follow mode.');
1211
+ }
1212
+ const sourceChoice = parseLogSource(interactiveSelection?.source ?? Option.getOrUndefined(opts.source));
1213
+ if (!sourceChoice.ok) {
1214
+ return yield* failBespoke(opts.json, 'logs', sourceChoice.message);
1215
+ }
1216
+ const { wantWorkers, wantHubspot } = sourceChoice;
1217
+ // The two sources resolve independently: Workers Logs need an HS-X account
1218
+ // and project id (the control plane mediates the read); HubSpot app logs
1219
+ // need only the local `.hs-x/hubspot.json` binding plus PAK credentials.
1220
+ // A missing prerequisite is fatal only when it leaves no source at all.
1221
+ const explicitAppId = Option.getOrUndefined(opts.appId);
1222
+ const selectedDifferentProject = projectSelection?.projects !== undefined && localProjectBinding?.projectId !== projectId;
1223
+ const hubspotResolution = wantHubspot
1224
+ ? selectedDifferentProject && explicitAppId === undefined
1225
+ ? {
1226
+ ok: false,
1227
+ reason: 'the selected project is not the project in the current directory; pass --app-id to include its HubSpot app logs',
1228
+ }
1229
+ : yield* Effect.promise(() => resolveHubSpotLogsSource({
1230
+ cwd,
1231
+ ...(explicitAppId !== undefined ? { appId: explicitAppId } : {}),
1232
+ }))
1233
+ : undefined;
1234
+ const hubspotSource = hubspotResolution?.ok
1235
+ ? hubspotResolution.source
1236
+ : undefined;
1237
+ const hubspotReason = hubspotResolution && !hubspotResolution.ok ? hubspotResolution.reason : undefined;
1238
+ const workersReason = !wantWorkers
1239
+ ? 'not requested (--source hubspot)'
1240
+ : !accountId
1241
+ ? 'no HS-X account (run `hs-x login`, or pass --account-id / HSX_ACCOUNT_ID)'
1242
+ : !projectId
1243
+ ? 'no project id'
1244
+ : undefined;
1245
+ const workersActive = workersReason === undefined;
1246
+ if (!workersActive && !hubspotSource) {
1247
+ if (wantWorkers && !accountId) {
1248
+ return yield* failBespoke(opts.json, 'logs', 'Logs require an HS-X account: run `hs-x login`, or pass --account-id / HSX_ACCOUNT_ID.');
1249
+ }
1250
+ if (wantWorkers && !projectId) {
1251
+ return yield* failBespoke(opts.json, 'logs', `Logs need a project: ${PROJECT_ID_HINT}.`);
1252
+ }
1253
+ return yield* failBespoke(opts.json, 'logs', `HubSpot app logs unavailable: ${hubspotReason ?? 'unknown'}.`);
1254
+ }
1255
+ const trace = Option.getOrUndefined(opts.trace);
1256
+ const limit = clampInt(opts.limit, 1, 200);
1257
+ const sinceMinutes = clampInt(interactiveSelection?.sinceMinutes ?? opts.sinceMinutes, 1, 7 * 24 * 60);
1258
+ const intervalSeconds = clampInt(opts.intervalSeconds, 1, 60);
1259
+ const view = trace ? 'trace' : opts.raw ? 'tail' : 'invocations';
1260
+ // Legacy single-source semantics only when Workers Logs is the sole
1261
+ // source: a control-plane failure hard-fails exactly as before. With a
1262
+ // second source in play, each side degrades to a warning instead.
1263
+ const strictWorkers = workersActive && !hubspotSource;
1264
+ const fetchWorkers = Effect.gen(function* () {
1265
+ const params = new URLSearchParams({ sinceMinutes: String(sinceMinutes) });
1266
+ if (trace) {
1267
+ params.set('invocation', trace);
1268
+ }
1269
+ else {
1270
+ // Workers Observability does not yet expose an errors-only predicate,
1271
+ // so read its maximum page before filtering to avoid hiding an error
1272
+ // behind a page of newer successful requests.
1273
+ params.set('limit', String(errorsOnly ? 200 : limit));
1274
+ if (!opts.raw)
1275
+ params.set('view', 'invocations');
1276
+ }
1277
+ const response = yield* cpGet(controlPlaneUrl, `/v1/accounts/${encodeURIComponent(accountId ?? '')}/projects/${encodeURIComponent(projectId ?? '')}/tail?${params.toString()}`, userId);
1278
+ const body = yield* Effect.promise(() => response.json());
1279
+ if (!response.ok) {
1280
+ const message = responseMessage(body, response.status, 'Control-plane logs read failed');
1281
+ if (strictWorkers) {
1282
+ yield* failBespoke(opts.json, 'logs', message);
1283
+ return { rows: [] };
1284
+ }
1285
+ return { rows: [], error: message };
1286
+ }
1287
+ return asLogsPayload(body);
1288
+ });
1289
+ const fetchHubspot = hubspotSource
1290
+ ? Effect.promise(() => fetchHubSpotLogsPayload({
1291
+ source: hubspotSource,
1292
+ sinceMinutes,
1293
+ limit,
1294
+ systemTypes: parsedSystemTypes.systemTypes,
1295
+ errorsOnly,
1296
+ ...(trace ? { trace } : {}),
1297
+ }).catch((cause) => ({
1298
+ rows: [],
1299
+ error: cause instanceof Error ? cause.message : String(cause),
1300
+ })))
1301
+ : Effect.succeed(undefined);
1302
+ const fetchPayload = Effect.gen(function* () {
1303
+ const [workers, hubspot] = yield* Effect.all([workersActive ? fetchWorkers : Effect.succeed(undefined), fetchHubspot], { concurrency: 2 });
1304
+ const workersRows = errorsOnly
1305
+ ? (workers?.rows ?? []).filter(isErrorLogRow)
1306
+ : (workers?.rows ?? []);
1307
+ const hubspotRows = errorsOnly
1308
+ ? (hubspot?.rows ?? []).filter(isErrorLogRow)
1309
+ : (hubspot?.rows ?? []);
1310
+ const filteredWorkers = applyLogFilters(workersRows, parsedFilters.filters, parsedSample.rate);
1311
+ const filteredHubspot = applyLogFilters(hubspotRows, parsedFilters.filters, parsedSample.rate);
1312
+ const rows = hubspotSource
1313
+ ? mergeLogRows(filteredWorkers, filteredHubspot, trace ? Number.MAX_SAFE_INTEGER : limit)
1314
+ : trace
1315
+ ? filteredWorkers
1316
+ : filteredWorkers.slice(0, limit);
1317
+ const workersError = workers?.error;
1318
+ const hubspotError = hubspot && 'error' in hubspot ? hubspot.error : undefined;
1319
+ const allFailed = (workersActive ? workersError !== undefined : true) &&
1320
+ (hubspotSource ? hubspotError !== undefined : true);
1321
+ const failedSystemTypes = hubspot && 'failedSystemTypes' in hubspot
1322
+ ? hubspot.failedSystemTypes
1323
+ : undefined;
1324
+ return {
1325
+ rows,
1326
+ ...(allFailed ? { fatalError: workersError ?? hubspotError ?? 'log read failed' } : {}),
1327
+ ...(workersError !== undefined ? { workersError } : {}),
1328
+ ...(hubspotError !== undefined ? { hubspotError } : {}),
1329
+ ...(failedSystemTypes && failedSystemTypes.length > 0
1330
+ ? { hubspotFailedSystemTypes: failedSystemTypes }
1331
+ : {}),
1332
+ };
1333
+ });
1334
+ const payload = yield* fetchPayload;
1335
+ const mode = workersActive && hubspotSource
1336
+ ? 'merged-logs'
1337
+ : hubspotSource
1338
+ ? 'hubspot-app-logs'
1339
+ : 'workers-logs';
1340
+ const result = {
1341
+ ok: !payload.fatalError,
1342
+ command: 'logs',
1343
+ mode,
1344
+ view,
1345
+ ...(accountId ? { accountId } : {}),
1346
+ ...(projectId ? { projectId } : {}),
1347
+ sinceMinutes,
1348
+ limit,
1349
+ ...(wantHubspot ? { hubspotLogTypes: parsedSystemTypes.systemTypes } : {}),
1350
+ ...(errorsOnly ? { errorsOnly: true } : {}),
1351
+ ...(parsedFilters.filters.length > 0 ? { filters: parsedFilters.filters } : {}),
1352
+ ...(parsedSample.rate !== undefined ? { sample: parsedSample.rate } : {}),
1353
+ ...(trace ? { invocationId: trace } : {}),
1354
+ sources: {
1355
+ workers: {
1356
+ active: workersActive,
1357
+ ...(workersReason !== undefined ? { reason: workersReason } : {}),
1358
+ ...(payload.workersError !== undefined ? { error: payload.workersError } : {}),
1359
+ },
1360
+ hubspot: hubspotSource
1361
+ ? {
1362
+ active: true,
1363
+ appId: hubspotSource.appId,
1364
+ developerAccountId: hubspotSource.developerAccountId,
1365
+ ...(payload.hubspotError !== undefined ? { error: payload.hubspotError } : {}),
1366
+ ...(payload.hubspotFailedSystemTypes
1367
+ ? { failedSystemTypes: payload.hubspotFailedSystemTypes }
1368
+ : {}),
1369
+ }
1370
+ : {
1371
+ active: false,
1372
+ ...(wantHubspot && hubspotReason !== undefined
1373
+ ? { reason: hubspotReason }
1374
+ : { reason: 'not requested (--source workers)' }),
1375
+ },
1376
+ },
1377
+ rows: payload.rows,
1378
+ ...(payload.fatalError ? { error: payload.fatalError } : {}),
1379
+ };
1380
+ const reporter = yield* Reporter;
1381
+ const showOrigin = hubspotSource !== undefined && workersActive;
1382
+ const reportPartialFailures = Effect.gen(function* () {
1383
+ if (!payload.fatalError && payload.workersError !== undefined) {
1384
+ yield* reporter.info(`Workers Logs unavailable this read: ${withWorkersErrorHint(payload.workersError)}`);
1385
+ }
1386
+ if (!payload.fatalError && hubspotSource && payload.hubspotError !== undefined) {
1387
+ yield* reporter.info(`HubSpot app logs unavailable this read: ${payload.hubspotError}`);
1388
+ }
1389
+ if (payload.hubspotFailedSystemTypes && payload.hubspotFailedSystemTypes.length > 0) {
1390
+ yield* reporter.info(`HubSpot log types skipped (query failed): ${payload.hubspotFailedSystemTypes.join(', ')}`);
1391
+ }
1392
+ if (wantHubspot && !hubspotSource && workersActive && hubspotReason !== undefined) {
1393
+ yield* reporter.info(`HubSpot app logs skipped: ${hubspotReason}.`);
1394
+ }
1395
+ });
1396
+ const headerLabel = projectId ?? hubspotSource?.projectName ?? `app ${hubspotSource?.appId}`;
1397
+ if (follow) {
1398
+ const seen = new Set();
1399
+ yield* reporter.header(trace ? `${headerLabel} invocation ${trace}` : `${headerLabel} live`);
1400
+ const printNewRows = (rows) => Effect.gen(function* () {
1401
+ const fresh = orderLogRowsAscending(rows.filter((row) => {
1402
+ const key = stableLogRowKey(row);
1403
+ if (seen.has(key))
1404
+ return false;
1405
+ seen.add(key);
1406
+ return true;
1407
+ }));
1408
+ if (fresh.length > 0) {
1409
+ yield* emitLogRows(reporter, fresh, view, showOrigin);
1410
+ }
1411
+ });
1412
+ yield* printNewRows(payload.rows);
1413
+ yield* reportPartialFailures;
1414
+ if (payload.fatalError) {
1415
+ yield* reporter.error('HSX_E_LOGS_UNAVAILABLE', `Logs unavailable: ${withWorkersErrorHint(payload.fatalError)}`);
1416
+ return yield* Effect.fail(exitWith(1, 'HSX_E_LOGS_UNAVAILABLE'));
1417
+ }
1418
+ while (true) {
1419
+ yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000)));
1420
+ const next = yield* fetchPayload;
1421
+ yield* printNewRows(next.rows);
1422
+ if (next.fatalError) {
1423
+ yield* reporter.error('HSX_E_LOGS_UNAVAILABLE', `Logs unavailable: ${withWorkersErrorHint(next.fatalError)}`);
1424
+ return yield* Effect.fail(exitWith(1, 'HSX_E_LOGS_UNAVAILABLE'));
1425
+ }
1426
+ }
1427
+ }
1428
+ if (opts.json) {
1429
+ yield* reporter.raw(`${JSON.stringify(result, null, 2)}\n`);
1430
+ if (payload.fatalError)
1431
+ return yield* Effect.fail(exitWith(1, 'HSX_E_LOGS_UNAVAILABLE'));
1432
+ return;
1433
+ }
1434
+ yield* reporter.header(trace ? `${headerLabel} invocation ${trace}` : headerLabel);
1435
+ yield* emitLogRows(reporter, payload.rows, view, showOrigin);
1436
+ yield* reportPartialFailures;
1437
+ if (payload.fatalError) {
1438
+ yield* reporter.error('HSX_E_LOGS_UNAVAILABLE', `Logs unavailable: ${withWorkersErrorHint(payload.fatalError)}`);
1439
+ return yield* Effect.fail(exitWith(1, 'HSX_E_LOGS_UNAVAILABLE'));
1440
+ }
1441
+ yield* reporter.done(`${payload.rows.length} row${payload.rows.length === 1 ? '' : 's'}`, 0, view === 'invocations' && payload.rows.length > 0 ? logRowCounts(payload.rows) : undefined);
1442
+ });
1443
+ }
1444
+ function optionsForSelectedProject(opts, selection) {
1445
+ return {
1446
+ json: false,
1447
+ controlPlaneUrl: opts.controlPlaneUrl,
1448
+ userId: opts.userId,
1449
+ projectId: Option.some(selection.projectId),
1450
+ accountId: selection.accountId ? Option.some(selection.accountId) : opts.accountId,
1451
+ };
1452
+ }
1453
+ function checkpointWindowMinutes(checkpoint) {
1454
+ const from = Date.parse(checkpoint.window.from);
1455
+ const to = Date.parse(checkpoint.window.to);
1456
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from)
1457
+ return 24 * 60;
1458
+ return clampInt(Math.ceil((to - from) / 60_000), 1, 7 * 24 * 60);
1459
+ }
1460
+ /** In-process logs view used by the human checkpoint failure drill-down. */
1461
+ export function logsBodyForCheckpoint(opts, selection, checkpoint, failure) {
1462
+ const selected = optionsForSelectedProject(opts, selection);
1463
+ return logsBody({
1464
+ ...selected,
1465
+ limit: 50,
1466
+ sinceMinutes: checkpointWindowMinutes(checkpoint),
1467
+ trace: Option.none(),
1468
+ raw: false,
1469
+ filter: [`target=${failure.capabilityId}`],
1470
+ sample: Option.none(),
1471
+ follow: false,
1472
+ intervalSeconds: 3,
1473
+ source: Option.some('workers'),
1474
+ appId: Option.none(),
1475
+ type: [],
1476
+ errorsOnly: true,
1477
+ }, { projectSelection: selection });
1478
+ }
1479
+ /** In-process command bodies used by the human status drill-down. */
1480
+ export function logsBodyForStatus(opts, selection) {
1481
+ const selected = optionsForSelectedProject(opts, selection);
1482
+ return logsBody({
1483
+ ...selected,
1484
+ limit: 50,
1485
+ sinceMinutes: 15,
1486
+ trace: Option.none(),
1487
+ raw: false,
1488
+ filter: [],
1489
+ sample: Option.none(),
1490
+ follow: false,
1491
+ intervalSeconds: 3,
1492
+ source: Option.none(),
1493
+ appId: Option.none(),
1494
+ type: [],
1495
+ errorsOnly: false,
1496
+ }, { projectSelection: selection, forceInteractiveSetup: true });
1497
+ }
1498
+ export function routesBodyForStatus(opts, selection) {
1499
+ return routingBody('routes', optionsForSelectedProject(opts, selection));
1500
+ }
1501
+ export function driftBodyForStatus(opts, selection) {
1502
+ return driftBody(optionsForSelectedProject(opts, selection));
1503
+ }
532
1504
  // ─── routing / route / routes ──────────────────────────────────────────────────
533
1505
  /** Single Command value reused for `routing`, the `route` alias, and `routes`. */
534
1506
  function makeRoutingCmd(name) {
@@ -540,7 +1512,12 @@ export const routesCmd = makeRoutingCmd('routes');
540
1512
  function routingBody(commandName, opts) {
541
1513
  return Effect.gen(function* () {
542
1514
  const reportName = commandName === 'routes' ? 'routes' : 'routing';
543
- const projectId = yield* resolveProjectId(opts);
1515
+ const selection = yield* resolveOperationProjectForRead(opts, {
1516
+ command: reportName,
1517
+ message: 'Which project do you want routing for?',
1518
+ emptyMessage: 'No projects yet. Run `hs-x deploy` from a project to create one.',
1519
+ });
1520
+ const projectId = selection?.projectId;
544
1521
  if (!projectId) {
545
1522
  return yield* failBespoke(opts.json, reportName, `${commandName === 'routes' ? 'Routes need' : 'Routing status needs'} a project: ${PROJECT_ID_HINT}.`);
546
1523
  }