@indigoai-us/hq-cli 5.128.0 → 5.130.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.
@@ -0,0 +1,310 @@
1
+ import { ensureCognitoToken, } from "../utils/cognito-session.js";
2
+ import { vaultApiFetch } from "../utils/vault-api.js";
3
+ import { listReportTemplates } from "../lib/report-templates.js";
4
+ import { adoptReport, buildReportTarball, conventionReportDir, deployOrgContext, ReportPublishError, plainFailureReason, publishReport, } from "../lib/report-publish.js";
5
+ const DEPLOY_API_URL = process.env.HQ_DEPLOY_API_URL ?? "https://api.indigo-hq.com";
6
+ function reportPath(channelId, reportId) {
7
+ return `/v1/notify/channels/${encodeURIComponent(channelId)}/reports/${encodeURIComponent(reportId)}`;
8
+ }
9
+ function errorMessage(error) {
10
+ if (error instanceof ReportPublishError)
11
+ return plainFailureReason(error);
12
+ return "Report command failed. Check the report context and try again.";
13
+ }
14
+ function print(value, json) {
15
+ if (json)
16
+ console.log(JSON.stringify(value, null, 2));
17
+ else
18
+ console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
19
+ }
20
+ /** Explicit context has priority; discovery is the authenticated fallback for normal CLI use. */
21
+ export async function resolveReport(token, reportId, requestedChannelId) {
22
+ const explicit = requestedChannelId ?? process.env.HQ_REPORT_CHANNEL_ID;
23
+ if (explicit) {
24
+ const res = await vaultApiFetch({ token, path: reportPath(explicit, reportId) });
25
+ if (!res.ok)
26
+ throw new ReportPublishError("Report was not found in the supplied channel context.");
27
+ const body = await res.json();
28
+ if (!body.report)
29
+ throw new ReportPublishError("Report response was incomplete.");
30
+ return body.report;
31
+ }
32
+ // The report API is intentionally channel-scoped. Discover only among the
33
+ // caller's existing channels rather than inventing an unscoped report route.
34
+ const channels = await vaultApiFetch({ token, path: "/v1/notify/channels" });
35
+ if (!channels.ok)
36
+ throw new ReportPublishError("Could not resolve the report channel.");
37
+ const listed = await channels.json();
38
+ const matches = [];
39
+ for (const row of listed.channels ?? []) {
40
+ if (!row.channelId)
41
+ continue;
42
+ const res = await vaultApiFetch({ token, path: reportPath(row.channelId, reportId) });
43
+ if (res.status === 404)
44
+ continue;
45
+ if (!res.ok)
46
+ continue;
47
+ const body = await res.json();
48
+ if (body.report)
49
+ matches.push(body.report);
50
+ }
51
+ if (matches.length === 1)
52
+ return matches[0];
53
+ if (matches.length > 1)
54
+ throw new ReportPublishError("Report ID is ambiguous; pass --channel or set HQ_REPORT_CHANNEL_ID.");
55
+ throw new ReportPublishError("Report not found. Pass --channel or set HQ_REPORT_CHANNEL_ID.");
56
+ }
57
+ export async function patchReport(token, report, patch) {
58
+ const res = await vaultApiFetch({ token, path: reportPath(report.channelId, report.reportId), method: "PATCH", body: patch });
59
+ if (res.status === 409) {
60
+ const body = await res.json().catch(() => ({}));
61
+ if (body.error === "already-running")
62
+ throw new ReportPublishError("already running", "already-running");
63
+ throw new ReportPublishError("Report changed while it was being updated.");
64
+ }
65
+ if (!res.ok)
66
+ throw new ReportPublishError("Could not update the report record.");
67
+ const body = await res.json();
68
+ if (!body.report)
69
+ throw new ReportPublishError("Report update response was incomplete.");
70
+ return body.report;
71
+ }
72
+ function deployHeaders(token, json = true, extra = {}) {
73
+ return {
74
+ Authorization: `Bearer ${token}`,
75
+ Accept: "application/json",
76
+ ...(json ? { "Content-Type": "application/json" } : {}),
77
+ ...extra,
78
+ };
79
+ }
80
+ async function deployJson(token, route, method, body, extraHeaders = {}) {
81
+ let res;
82
+ try {
83
+ res = await fetch(`${DEPLOY_API_URL.replace(/\/+$/, "")}${route}`, {
84
+ method,
85
+ headers: deployHeaders(token, body !== undefined, extraHeaders),
86
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
87
+ });
88
+ }
89
+ catch {
90
+ throw new ReportPublishError("Could not reach hq-deploy.");
91
+ }
92
+ if (!res.ok)
93
+ throw new ReportPublishError("hq-deploy rejected the report deployment.");
94
+ return await res.json();
95
+ }
96
+ export async function resolveCompanySlug(token, companyUid) {
97
+ const res = await vaultApiFetch({ token, path: `/entity/${encodeURIComponent(companyUid)}` });
98
+ if (!res.ok)
99
+ throw new ReportPublishError("Could not resolve the report's company for deploy.");
100
+ const body = await res.json();
101
+ const slug = body.entity?.slug;
102
+ if (!slug)
103
+ throw new ReportPublishError("Could not resolve the report's company for deploy.");
104
+ return slug;
105
+ }
106
+ async function orgFor(token, report) {
107
+ const slug = report.companyUid ? await resolveCompanySlug(token, report.companyUid) : undefined;
108
+ return deployOrgContext(report, slug);
109
+ }
110
+ async function findOrCreateApp(token, slug, org) {
111
+ const listed = await deployJson(token, "/api/apps", "GET", undefined, org.headers);
112
+ const existing = Array.isArray(listed.apps)
113
+ ? listed.apps.find((app) => !!app && typeof app === "object" && (app.subdomain === slug || app.name === slug))
114
+ : undefined;
115
+ const app = existing ?? await deployJson(token, "/api/apps", "POST", { name: slug, type: "static" }, org.headers);
116
+ const id = typeof app.id === "string" ? app.id : undefined;
117
+ const subdomain = typeof app.subdomain === "string" ? app.subdomain : undefined;
118
+ if (!id || !subdomain)
119
+ throw new ReportPublishError("hq-deploy returned an incomplete app record.");
120
+ return { id, subdomain };
121
+ }
122
+ export async function deployReportStatic(token, input) {
123
+ const app = await findOrCreateApp(token, input.slug, input.org);
124
+ await deployJson(token, `/api/apps/${encodeURIComponent(app.id)}/access-policy`, "PUT", input.access, input.org.headers);
125
+ const tarball = buildReportTarball(input.files);
126
+ const deployment = await deployJson(token, "/api/deploys", "POST", {
127
+ appSlug: app.subdomain,
128
+ ...(input.org.org ? { org: input.org.org } : {}),
129
+ manifest: { files: [], size: tarball.sizeBytes, sha256: tarball.sha256 },
130
+ }, input.org.headers);
131
+ const deployId = typeof deployment.deployId === "string" ? deployment.deployId : undefined;
132
+ const presignedUrl = typeof deployment.presignedUrl === "string" ? deployment.presignedUrl : undefined;
133
+ if (!deployId || !presignedUrl)
134
+ throw new ReportPublishError("hq-deploy did not create an upload.");
135
+ let upload;
136
+ try {
137
+ upload = await fetch(presignedUrl, { method: "PUT", headers: { "Content-Type": "application/gzip" }, body: tarball.bytes });
138
+ }
139
+ catch {
140
+ throw new ReportPublishError("Report upload failed.");
141
+ }
142
+ if (!upload.ok)
143
+ throw new ReportPublishError("Report upload failed.");
144
+ const completed = await deployJson(token, `/api/deploys/${encodeURIComponent(deployId)}/complete`, "POST", { appSlug: app.subdomain }, input.org.headers);
145
+ const url = typeof completed.url === "string" ? completed.url : `https://${app.subdomain}.indigo-hq.com`;
146
+ return { appId: app.id, url, deployId };
147
+ }
148
+ async function publishWithOrg(token, report, directory, draft) {
149
+ return publishReport(report, directory, draft, {
150
+ patch: (current, patch) => patchReport(token, current, patch),
151
+ deploy: async (input) => {
152
+ const org = await orgFor(token, report);
153
+ return deployReportStatic(token, { ...input, org });
154
+ },
155
+ now: () => new Date().toISOString(),
156
+ });
157
+ }
158
+ async function publishAction(reportId, options) {
159
+ const token = await ensureCognitoToken();
160
+ const report = await resolveReport(token, reportId, options.channel);
161
+ print(await publishWithOrg(token, report, options.dir, options.draft === true), options.json === true);
162
+ }
163
+ async function promoteAction(reportId, options) {
164
+ const token = await ensureCognitoToken();
165
+ const report = await resolveReport(token, reportId, options.channel);
166
+ const directory = options.dir?.trim() ? options.dir : conventionReportDir(reportId);
167
+ const published = await publishWithOrg(token, report, directory, false);
168
+ // A promotion makes the freshly deployed live page authoritative. Null clears
169
+ // the draft pointer; omitting the field would leave the old draft in place.
170
+ const updated = published.draft
171
+ ? await patchReport(token, published, { draft: null, state: "live" })
172
+ : published;
173
+ print(updated, options.json === true);
174
+ }
175
+ async function getAction(reportId, options) {
176
+ const report = await resolveReport(await ensureCognitoToken(), reportId, options.channel);
177
+ print(report, options.json === true);
178
+ }
179
+ async function discardDraftAction(reportId, options) {
180
+ const token = await ensureCognitoToken();
181
+ const report = await resolveReport(token, reportId, options.channel);
182
+ const org = await orgFor(token, report);
183
+ if (report.draft?.appId) {
184
+ const res = await fetch(`${DEPLOY_API_URL.replace(/\/+$/, "")}/api/apps/${encodeURIComponent(report.draft.appId)}`, {
185
+ method: "DELETE",
186
+ headers: deployHeaders(token, false, org.headers),
187
+ });
188
+ if (!res.ok && res.status !== 404)
189
+ throw new ReportPublishError("Could not remove the draft deployment.");
190
+ }
191
+ const updated = await patchReport(token, report, { draft: null, state: report.url ? "live" : "draft-pending" });
192
+ print(updated, options.json === true);
193
+ }
194
+ async function accessAction(reportId, options) {
195
+ if (!["company", "selected", "password", "public"].includes(options.mode)) {
196
+ throw new ReportPublishError("Access mode must be company, selected, password, or public.");
197
+ }
198
+ if (options.mode === "password" && !options.password) {
199
+ throw new ReportPublishError("A password is required for password access mode.");
200
+ }
201
+ const token = await ensureCognitoToken();
202
+ const report = await resolveReport(token, reportId, options.channel);
203
+ const appId = report.deployAppId ?? report.draft?.appId;
204
+ if (!appId)
205
+ throw new ReportPublishError("Publish the report before changing its access policy.");
206
+ const companyUid = report.companyUid ?? "personal";
207
+ const policy = {
208
+ mode: options.mode,
209
+ companyUid,
210
+ users: options.mode === "selected" ? [{ id: report.createdBy }] : [],
211
+ groups: [],
212
+ ...(options.mode === "password" && options.password ? { password: options.password } : {}),
213
+ };
214
+ const org = await orgFor(token, report);
215
+ const result = await deployJson(token, `/api/apps/${encodeURIComponent(appId)}/access-policy`, "PUT", policy, org.headers);
216
+ print(result, options.json === true);
217
+ }
218
+ async function cancelOwnedJob(token, agentUid, jobId) {
219
+ const res = await vaultApiFetch({
220
+ token,
221
+ path: `/v1/agents/${encodeURIComponent(agentUid)}/jobs/${encodeURIComponent(jobId)}/cancel`,
222
+ method: "POST",
223
+ });
224
+ if (!res.ok && res.status !== 404)
225
+ throw new ReportPublishError("Could not remove the report job.");
226
+ }
227
+ async function adoptAction(reportId, options) {
228
+ const token = await ensureCognitoToken();
229
+ const report = await resolveReport(token, reportId, options.channel);
230
+ const updated = await adoptReport(report, {
231
+ cancelJob: (jobId) => cancelOwnedJob(token, report.ownerAgentUid, jobId),
232
+ createJob: async (input) => {
233
+ const res = await vaultApiFetch({
234
+ token,
235
+ path: `/v1/agents/${encodeURIComponent(report.ownerAgentUid)}/jobs`,
236
+ method: "POST",
237
+ body: input,
238
+ });
239
+ if (!res.ok)
240
+ throw new ReportPublishError("Could not install the report job.");
241
+ const body = await res.json();
242
+ const jobId = body.job?.jobId ?? body.jobId;
243
+ if (!jobId)
244
+ throw new ReportPublishError("Jobs API returned an incomplete job.");
245
+ return { jobId };
246
+ },
247
+ patch: (current, patch) => patchReport(token, current, patch),
248
+ });
249
+ print(updated, options.json === true);
250
+ }
251
+ async function decommissionAction(reportId, options) {
252
+ const token = await ensureCognitoToken();
253
+ const report = await resolveReport(token, reportId, options.channel);
254
+ const org = await orgFor(token, report);
255
+ const leftover = [];
256
+ for (const id of [report.deployAppId, report.draft?.appId]) {
257
+ if (!id)
258
+ continue;
259
+ try {
260
+ const res = await fetch(`${DEPLOY_API_URL.replace(/\/+$/, "")}/api/apps/${encodeURIComponent(id)}`, {
261
+ method: "DELETE",
262
+ headers: deployHeaders(token, false, org.headers),
263
+ });
264
+ if (res.status === 404)
265
+ leftover.push("The published page was already gone.");
266
+ else if (!res.ok)
267
+ leftover.push("A deployment could not be deactivated.");
268
+ }
269
+ catch {
270
+ leftover.push("A deployment could not be reached.");
271
+ }
272
+ }
273
+ // The durable report is marked decommissioned by hq-pro before this prompt.
274
+ // Cleanup gaps stay on the result so a missing app or offline bot does not roll the record back.
275
+ if (report.jobId) {
276
+ try {
277
+ await cancelOwnedJob(token, report.ownerAgentUid, report.jobId);
278
+ }
279
+ catch {
280
+ leftover.push("The report job is still installed.");
281
+ }
282
+ }
283
+ const note = leftover.length ? [...new Set(leftover)].join(" ") : null;
284
+ print({ reportId: report.reportId, decommissioned: true, note }, options.json === true);
285
+ }
286
+ export function registerReportCommand(program) {
287
+ const report = program.command("report").description("Publish and manage a channel report owned by this bot.");
288
+ const context = (command) => command.option("--channel <channelId>", "report channel context (or set HQ_REPORT_CHANNEL_ID)");
289
+ context(report.command("get <reportId>").option("--json").action((reportId, options) => runReportCommand(() => getAction(reportId, options))));
290
+ context(report.command("publish <reportId>").requiredOption("--dir <path>").option("--draft").option("--json").action((reportId, options) => runReportCommand(() => publishAction(reportId, options))));
291
+ context(report.command("promote <reportId>").option("--dir <path>", "report folder; defaults to <workerDir>/reports/<reportId>").option("--json").action((reportId, options) => runReportCommand(() => promoteAction(reportId, options))));
292
+ context(report.command("discard-draft <reportId>").option("--json").action((reportId, options) => runReportCommand(() => discardDraftAction(reportId, options))));
293
+ context(report.command("access <reportId>").requiredOption("--mode <mode>", "company|selected|password|public").option("--password <password>").option("--json").action((reportId, options) => runReportCommand(() => accessAction(reportId, options))));
294
+ context(report.command("decommission <reportId>").option("--json").action((reportId, options) => runReportCommand(() => decommissionAction(reportId, options))));
295
+ context(report.command("adopt <reportId>").option("--json").action((reportId, options) => runReportCommand(() => adoptAction(reportId, options))));
296
+ report.command("templates").option("--json").action((options) => {
297
+ const templates = listReportTemplates().map(({ name, path: templatePath }) => ({ name, path: templatePath }));
298
+ print(templates, options.json === true);
299
+ });
300
+ }
301
+ export async function runReportCommand(action) {
302
+ try {
303
+ await action();
304
+ }
305
+ catch (error) {
306
+ console.error(errorMessage(error));
307
+ process.exitCode = 1;
308
+ }
309
+ }
310
+ //# sourceMappingURL=report.js.map
@@ -3,14 +3,17 @@
3
3
  *
4
4
  * The stdin event schema is deliberately closed. Hook payloads can contain
5
5
  * commands, prompts, source content, transcript locations, and credentials, so
6
- * this module accepts only a small set of scalar diagnostics and never reads a
7
- * hook payload or the process environment. The owner-approved `hook_path`
6
+ * this module accepts only a small set of scalar diagnostics plus bounded hook
7
+ * history and never reads a hook payload or the process environment. The
8
+ * owner-approved `hook_path`
8
9
  * diagnostic is the narrow exception: it names the slow hook script and may
9
10
  * include its company directory, but never carries the hook payload itself.
10
11
  *
11
- * Invalid top-level input and structurally rich metadata are rejected rather
12
- * than truncated. Unknown scalar metadata is dropped instead: the allowlist,
13
- * not a caller version match, determines what may leave the machine.
12
+ * Invalid top-level input and structurally rich metadata are rejected. The
13
+ * bounded hook sequence is the deliberate exception: it keeps only the most
14
+ * recent entries so diagnostics cannot expand the transport payload. Unknown
15
+ * scalar metadata is dropped instead: the allowlist, not a caller version
16
+ * match, determines what may leave the machine.
14
17
  */
15
18
  import * as Sentry from "@sentry/node";
16
19
  import type { CaptureContext, SeverityLevel } from "@sentry/node";
@@ -24,9 +27,15 @@ export declare const MAX_SENTRY_REPORT_METADATA_VALUE_BYTES = 256;
24
27
  * is owner-approved telemetry for the exact hook script and may include a
25
28
  * company directory; it is never a substitute for hook input or a transcript.
26
29
  */
27
- export declare const ALLOWED_SENTRY_REPORT_METADATA_KEYS: readonly ["hook_name", "hook_path", "hook_event", "tool_name", "session_id", "declared_timeout_ms", "elapsed_ms", "remaining_ms", "watchdog_timeout_ms", "hook_timeout_ms", "hq_version", "platform", "load_average"];
30
+ export declare const ALLOWED_SENTRY_REPORT_METADATA_KEYS: readonly ["hook_name", "hook_path", "hook_event", "hook_script", "tool_name", "session_id", "declared_timeout_ms", "elapsed_ms", "remaining_ms", "watchdog_timeout_ms", "hook_timeout_ms", "hq_version", "platform", "load_average", "exit_code", "final_elapsed_ms", "bash_env_set", "cwd_kind", "shell", "nproc", "hook_sequence"];
28
31
  export declare const MAX_SENTRY_REPORT_METADATA_KEYS: number;
29
- export type SentryReportMetadata = Record<string, string | number | boolean>;
32
+ export type HookSequenceEntry = {
33
+ script: string;
34
+ event: string;
35
+ ms: number;
36
+ };
37
+ export type SentryReportMetadataValue = string | number | boolean | HookSequenceEntry[];
38
+ export type SentryReportMetadata = Record<string, SentryReportMetadataValue>;
30
39
  export type SentryReportEvent = {
31
40
  type: string;
32
41
  message: string;
@@ -38,15 +47,26 @@ export type SentryReportOptions = {
38
47
  dryRun?: boolean;
39
48
  /** Total time reserved for the report flush after validation. */
40
49
  timeoutMs?: number;
50
+ /** Explicit live HQ root supplied by `hq core --hq-root`. */
51
+ hqRoot?: string;
52
+ /** Test seam for the bounded timeout-report stamp store. */
53
+ rateLimitDirectory?: string;
54
+ /** Test seam for deterministic rate-limit windows. */
55
+ rateLimitNow?: () => number;
41
56
  };
42
57
  export type SentryReportDependencies = {
43
58
  sentry: {
44
59
  captureMessage: (message: string, context?: CaptureContext | SeverityLevel) => string;
60
+ addBreadcrumb: (breadcrumb: Parameters<typeof Sentry.addBreadcrumb>[0]) => void;
45
61
  metrics: Pick<typeof Sentry.metrics, "count">;
46
62
  flush: (timeout?: number) => PromiseLike<boolean>;
47
63
  };
48
64
  stdout: Pick<NodeJS.WriteStream, "write">;
49
65
  stderr: Pick<NodeJS.WriteStream, "write">;
66
+ /** Test seam; production resolves the live HQ workspace tmp directory. */
67
+ rateLimitDirectory?: string;
68
+ /** Test seam; production uses Date.now. */
69
+ rateLimitNow?: () => number;
50
70
  };
51
71
  export type SentryReportResult = {
52
72
  code: 0 | 1;