@indigoai-us/hq-cli 5.321.0 → 5.323.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 (32) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/assets/scaffold/core/scripts/derive-trigger-facts.sh +263 -0
  3. package/assets/scaffold/core/scripts/lib/transcript-tail.sh +98 -0
  4. package/assets/scaffold/core/scripts/lib/trigger-fact-text.awk +86 -0
  5. package/dist/command-catalog.generated.d.ts +13 -0
  6. package/dist/command-catalog.generated.js +16 -0
  7. package/dist/commands/cloud.js +23 -4
  8. package/dist/commands/core-derive-trigger-facts-command.d.ts +3 -0
  9. package/dist/commands/core-derive-trigger-facts-command.js +47 -0
  10. package/dist/commands/core-derive-trigger-facts-native.d.ts +21 -0
  11. package/dist/commands/core-derive-trigger-facts-native.js +492 -0
  12. package/dist/commands/core.js +6 -0
  13. package/dist/commands/daemon.js +1 -1
  14. package/dist/fast-scaffold.js +13 -0
  15. package/dist/lib/cli-invite-nudge.d.ts +25 -0
  16. package/dist/lib/cli-invite-nudge.js +152 -0
  17. package/dist/lib/daemon/requests.d.ts +1 -0
  18. package/dist/lib/daemon/requests.js +1 -1
  19. package/dist/lib/daemon/run.js +5 -4
  20. package/dist/lib/daemon/succession.js +1 -1
  21. package/dist/lib/daemon/supervisor.d.ts +2 -1
  22. package/dist/lib/daemon/supervisor.js +134 -7
  23. package/dist/lib/first-sync-next-step.d.ts +10 -1
  24. package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
  25. package/dist/lib/mesh/live/daemon/run.js +36 -15
  26. package/dist/main.js +3 -1
  27. package/dist/utils/cli-telemetry.d.ts +11 -0
  28. package/dist/utils/cli-telemetry.js +76 -0
  29. package/dist/utils/run-bundled-script.d.ts +7 -4
  30. package/dist/utils/run-bundled-script.js +47 -12
  31. package/dist/utils/vault-api.js +87 -37
  32. package/package.json +1 -1
@@ -17,14 +17,17 @@ export const TELEMETRY_TIMEOUT_MS = 1_200;
17
17
  */
18
18
  export const TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp, never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
19
19
  export const FIRST_SYNC_TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when you run a signed-in command (CLI version and a timestamp; first-sync guidance shown or followed, with the identifier cli.first-sync-next-step; never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.";
20
+ export const CLI_INVITE_NUDGE_TELEMETRY_DISCLOSURE_LINE = "HQ sends a usage event when it shows the teammate invite prompt (your company UID and whether you saw, clicked, or dismissed the prompt; no invitee details, command names, file contents, or secrets). Set HQ_TELEMETRY=0 to turn this off.";
20
21
  export const CLI_FEATURE_USED_TELEMETRY_FLAG = "cli.feature-used-telemetry-v1";
21
22
  export const CLI_FEATURE_USED_TELEMETRY_DISCLOSURE_LINE = "HQ sends a feature-use event when you run a supported CLI feature (feature kind and outcome, plus your company UID when available; never command names, file contents, or secrets). The hq-flags key cli.feature-used-telemetry-v1 controls these events. Set HQ_TELEMETRY=0 to turn this off.";
22
23
  const TELEMETRY_DISABLED_VALUES = new Set(["0", "false", "off"]);
23
24
  const DISCLOSURE_MARKER = "cli-telemetry-disclosed";
24
25
  const FIRST_SYNC_DISCLOSURE_MARKER = "cli-telemetry-disclosed-first-sync-v1";
26
+ const INVITE_NUDGE_DISCLOSURE_MARKER = "cli-telemetry-disclosed-invite-nudge-v1";
25
27
  const FEATURE_USED_DISCLOSURE_MARKER = "cli-telemetry-disclosed-feature-used-v1";
26
28
  let cliSessionStartedPromise;
27
29
  const pendingPlanLimitInviteTeammateExposures = [];
30
+ const pendingCliInviteNudgeEvents = [];
28
31
  export const CLI_LIMIT_AT_ACTION_PROMPT_FLAG = "billing.limit-at-action-prompt";
29
32
  const pendingCliLimitNoticeExposures = [];
30
33
  const pendingCliLimitNoticeEngagements = [];
@@ -68,6 +71,73 @@ export function queuePlanLimitInviteTeammateExposure(companyUid) {
68
71
  export function takePlanLimitInviteTeammateExposures() {
69
72
  return pendingPlanLimitInviteTeammateExposures.splice(0, pendingPlanLimitInviteTeammateExposures.length);
70
73
  }
74
+ /** Queue one company-keyed CLI invite prompt outcome; no person UID is included in the event. */
75
+ export function queueCliInviteNudgeEvent(companyUid, outcome) {
76
+ if (!/^cmp_[A-Za-z0-9_-]{1,128}$/.test(companyUid))
77
+ return;
78
+ pendingCliInviteNudgeEvents.push({ companyUid, outcome });
79
+ }
80
+ export function takeCliInviteNudgeEvents() {
81
+ return pendingCliInviteNudgeEvents.splice(0, pendingCliInviteNudgeEvents.length);
82
+ }
83
+ /** Emit a bounded CLI session fact through the existing product-telemetry path. */
84
+ export async function emitCliInviteNudgeEvent(event, timeoutMs = TELEMETRY_TIMEOUT_MS) {
85
+ try {
86
+ if (isCliTelemetryDisabled())
87
+ return;
88
+ const cached = loadCachedTokens();
89
+ if (!cached || isExpiring(cached, 120) || !cached.accessToken ||
90
+ !isHumanInteractiveCliSession({
91
+ stdoutIsTTY: Boolean(process.stdout.isTTY),
92
+ argv: process.argv,
93
+ env: process.env,
94
+ machineIdentity: isMachineIdentity(),
95
+ }) || process.stdin.isTTY !== true)
96
+ return;
97
+ if (!maybeDiscloseInviteNudgeTelemetry())
98
+ return;
99
+ const controller = new AbortController();
100
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
101
+ try {
102
+ const response = await vaultApiFetch({
103
+ token: cached.accessToken,
104
+ path: "/v1/telemetry/events",
105
+ method: "POST",
106
+ body: {
107
+ events: [{
108
+ eventName: "cli_session_started",
109
+ app: "hq-cli",
110
+ source: "cli",
111
+ occurredAt: new Date().toISOString(),
112
+ consentBasis: "disclosed",
113
+ schemaVersion: 1,
114
+ companyUid: event.companyUid,
115
+ properties: {
116
+ version: CLI_VERSION,
117
+ step: "invite_teammate_nudge",
118
+ outcome: event.outcome,
119
+ },
120
+ }],
121
+ },
122
+ signal: controller.signal,
123
+ });
124
+ if (!response.ok) {
125
+ console.error(`hq: Could not send invite prompt telemetry (http_${response.status}).`);
126
+ }
127
+ }
128
+ catch (error) {
129
+ const errorClass = error instanceof Error ? error.name : "unknown";
130
+ console.error(`hq: Could not send invite prompt telemetry (${errorClass}).`);
131
+ }
132
+ finally {
133
+ clearTimeout(timeout);
134
+ }
135
+ }
136
+ catch (error) {
137
+ const errorClass = error instanceof Error ? error.name : "unknown";
138
+ console.error(`hq: Could not prepare invite prompt telemetry (${errorClass}).`);
139
+ }
140
+ }
71
141
  const COMPANY_UID_PATTERN = /^cmp_[A-Za-z0-9_-]{1,128}$/;
72
142
  export function isCliLimitResourceKind(value) {
73
143
  return ["users", "integrations", "secrets", "agents", "deployments", "storageBytes"].includes(value);
@@ -172,6 +242,9 @@ export function telemetryDisclosureMarkerPath() {
172
242
  function firstSyncTelemetryDisclosureMarkerPath() {
173
243
  return path.join(hqConfigDir(), FIRST_SYNC_DISCLOSURE_MARKER);
174
244
  }
245
+ function inviteNudgeTelemetryDisclosureMarkerPath() {
246
+ return path.join(hqConfigDir(), INVITE_NUDGE_DISCLOSURE_MARKER);
247
+ }
175
248
  function featureUsedTelemetryDisclosureMarkerPath() {
176
249
  return path.join(hqConfigDir(), FEATURE_USED_DISCLOSURE_MARKER);
177
250
  }
@@ -212,6 +285,9 @@ export function maybeDiscloseCliTelemetry(stderr = process.stderr, stdout = proc
212
285
  function maybeDiscloseFirstSyncTelemetry() {
213
286
  return maybeDiscloseTelemetry(FIRST_SYNC_TELEMETRY_DISCLOSURE_LINE, firstSyncTelemetryDisclosureMarkerPath());
214
287
  }
288
+ function maybeDiscloseInviteNudgeTelemetry() {
289
+ return maybeDiscloseTelemetry(CLI_INVITE_NUDGE_TELEMETRY_DISCLOSURE_LINE, inviteNudgeTelemetryDisclosureMarkerPath());
290
+ }
215
291
  function maybeDiscloseFeatureUsedTelemetry() {
216
292
  return maybeDiscloseTelemetry(CLI_FEATURE_USED_TELEMETRY_DISCLOSURE_LINE, featureUsedTelemetryDisclosureMarkerPath());
217
293
  }
@@ -8,10 +8,11 @@
8
8
  *
9
9
  * Deliberate choices:
10
10
  *
11
- * - `stdio: "inherit"`. Not piped. Piping would buffer output that callers
12
- * stream, reorder stdout against stderr, and require re-emitting bytes we
13
- * have no reason to touch. Inheriting also means a script that reads stdin
14
- * (the hook payload pattern) gets the real fd, unbuffered and unconsumed.
11
+ * - stdio inherits by default. Captured runs pipe only stdout/stderr, and
12
+ * inherit stdin unless the caller supplies an explicit payload. Piping
13
+ * output would buffer streams, reorder stdout against stderr, and require
14
+ * re-emitting bytes, so capture remains an explicit opt-in for native
15
+ * parity fallbacks that need the shell's exact output bytes.
15
16
  *
16
17
  * - an explicit interpreter (`bash` or `node`) rather than executing the file
17
18
  * directly. The executable bit does survive `npm publish` for files that
@@ -52,6 +53,8 @@ export type RunBundledScriptOptions = {
52
53
  env?: Record<string, string | undefined>;
53
54
  /** Capture stdout and stderr as bytes instead of inheriting those streams. */
54
55
  captureOutput?: boolean;
56
+ /** Bytes supplied on stdin. With no value, stdin is inherited as before. */
57
+ stdin?: string | Buffer;
55
58
  };
56
59
  export type RunBundledScriptResult = {
57
60
  /** The child's exit code, or 128+signal if it was terminated by one. */
@@ -8,10 +8,11 @@
8
8
  *
9
9
  * Deliberate choices:
10
10
  *
11
- * - `stdio: "inherit"`. Not piped. Piping would buffer output that callers
12
- * stream, reorder stdout against stderr, and require re-emitting bytes we
13
- * have no reason to touch. Inheriting also means a script that reads stdin
14
- * (the hook payload pattern) gets the real fd, unbuffered and unconsumed.
11
+ * - stdio inherits by default. Captured runs pipe only stdout/stderr, and
12
+ * inherit stdin unless the caller supplies an explicit payload. Piping
13
+ * output would buffer streams, reorder stdout against stderr, and require
14
+ * re-emitting bytes, so capture remains an explicit opt-in for native
15
+ * parity fallbacks that need the shell's exact output bytes.
15
16
  *
16
17
  * - an explicit interpreter (`bash` or `node`) rather than executing the file
17
18
  * directly. The executable bit does survive `npm publish` for files that
@@ -30,6 +31,7 @@
30
31
  */
31
32
  import { spawnSync } from "child_process";
32
33
  import * as fs from "node:fs";
34
+ import * as os from "node:os";
33
35
  import * as path from "path";
34
36
  import { resolveBundledAsset } from "./hq-roots.js";
35
37
  /**
@@ -152,12 +154,41 @@ export function runBundledScript(opts) {
152
154
  if (windowsBash && !windowsBash.executable) {
153
155
  throw bashNotFoundError(opts.asset, windowsBash.cause);
154
156
  }
155
- const result = spawnSync(executable, [script, ...(opts.args ?? [])], {
156
- cwd: opts.cwd,
157
- env,
158
- stdio: opts.captureOutput ? ["inherit", "pipe", "pipe"] : "inherit",
159
- ...(opts.captureOutput ? { maxBuffer: Number.MAX_SAFE_INTEGER } : {}),
160
- });
157
+ let inputDirectory;
158
+ let inputDescriptor;
159
+ let result;
160
+ try {
161
+ if (opts.stdin !== undefined) {
162
+ // Some scaffold scripts reopen /dev/stdin by pathname. Node's spawnSync
163
+ // input pipe cannot be reopened through /dev/stdin on Linux, so capture
164
+ // mode supplies a private regular file descriptor to preserve that ABI.
165
+ const sharedMemory = process.platform === "linux" && fs.existsSync("/dev/shm")
166
+ ? "/dev/shm"
167
+ : os.tmpdir();
168
+ inputDirectory = fs.mkdtempSync(path.join(sharedMemory, "hq-bundled-stdin-"));
169
+ const inputPath = path.join(inputDirectory, "stdin");
170
+ fs.writeFileSync(inputPath, opts.stdin, { mode: 0o600 });
171
+ inputDescriptor = fs.openSync(inputPath, "r");
172
+ }
173
+ const stdinStdio = inputDescriptor ?? "inherit";
174
+ const stdio = opts.captureOutput
175
+ ? [stdinStdio, "pipe", "pipe"]
176
+ : inputDescriptor !== undefined
177
+ ? [stdinStdio, "inherit", "inherit"]
178
+ : "inherit";
179
+ result = spawnSync(executable, [script, ...(opts.args ?? [])], {
180
+ cwd: opts.cwd,
181
+ env,
182
+ stdio,
183
+ ...(opts.captureOutput ? { maxBuffer: Number.MAX_SAFE_INTEGER } : {}),
184
+ });
185
+ }
186
+ finally {
187
+ if (inputDescriptor !== undefined)
188
+ fs.closeSync(inputDescriptor);
189
+ if (inputDirectory)
190
+ fs.rmSync(inputDirectory, { recursive: true, force: true });
191
+ }
161
192
  if (result.error) {
162
193
  const spawnError = result.error;
163
194
  if (process.platform === "win32" &&
@@ -185,8 +216,12 @@ export function runBundledScript(opts) {
185
216
  return {
186
217
  code,
187
218
  ...(opts.captureOutput ? {
188
- stdout: result.stdout ?? Buffer.alloc(0),
189
- stderr: result.stderr ?? Buffer.alloc(0),
219
+ stdout: Buffer.isBuffer(result.stdout)
220
+ ? result.stdout
221
+ : Buffer.from(result.stdout ?? ""),
222
+ stderr: Buffer.isBuffer(result.stderr)
223
+ ? result.stderr
224
+ : Buffer.from(result.stderr ?? ""),
190
225
  } : {}),
191
226
  };
192
227
  }
@@ -23,6 +23,57 @@ import { readCachedScopeUid, writeCachedScopeUid } from './secrets-cache.js';
23
23
  * whole-process watchdog.
24
24
  */
25
25
  const IDENTITY_LOOKUP_TIMEOUT_MS = 15_000;
26
+ const LAMBDA_INVOKE_RETRY_MAX_DELAY_MS = 100;
27
+ function isLambdaInvokeServiceError(response, bodyText) {
28
+ if (response.status !== 504) {
29
+ return false;
30
+ }
31
+ try {
32
+ const body = JSON.parse(bodyText);
33
+ return (typeof body === 'object' &&
34
+ body !== null &&
35
+ !Array.isArray(body) &&
36
+ Object.keys(body).length === 1 &&
37
+ body.message === 'Internal server error');
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ async function fetchWithLambdaInvokeRetry(fetchOnce, retryEligible, signal) {
44
+ const first = await fetchOnce();
45
+ if (!retryEligible || first.status !== 504)
46
+ return first;
47
+ const bodyText = await first.text();
48
+ if (!isLambdaInvokeServiceError(first, bodyText)) {
49
+ return new Response(bodyText, {
50
+ status: first.status,
51
+ statusText: first.statusText,
52
+ headers: first.headers,
53
+ });
54
+ }
55
+ const delay = 25 + Math.floor(Math.random() * (LAMBDA_INVOKE_RETRY_MAX_DELAY_MS - 24));
56
+ await jitteredDelayWithAbort(delay, signal);
57
+ return fetchOnce();
58
+ }
59
+ function jitteredDelayWithAbort(ms, signal) {
60
+ if (signal?.aborted) {
61
+ return Promise.reject(signal.reason ?? new DOMException('The operation was aborted', 'AbortError'));
62
+ }
63
+ return new Promise((resolve, reject) => {
64
+ const cleanup = () => signal?.removeEventListener('abort', abort);
65
+ const timer = setTimeout(() => {
66
+ cleanup();
67
+ resolve();
68
+ }, ms);
69
+ const abort = () => {
70
+ clearTimeout(timer);
71
+ cleanup();
72
+ reject(signal?.reason ?? new DOMException('The operation was aborted', 'AbortError'));
73
+ };
74
+ signal?.addEventListener('abort', abort, { once: true });
75
+ });
76
+ }
26
77
  /**
27
78
  * The identity-lookup abort bound, in milliseconds. Defaults to the shipped
28
79
  * IDENTITY_LOOKUP_TIMEOUT_MS (15s). An optional HQ_IDENTITY_LOOKUP_TIMEOUT_MS
@@ -192,41 +243,40 @@ export async function vaultApiFetch(opts) {
192
243
  level: "info",
193
244
  data: { url: safeUrl, method },
194
245
  });
195
- let response;
196
- try {
197
- response = await fetch(url.toString(), {
198
- method,
199
- headers: {
200
- Authorization: `Bearer ${opts.token}`,
201
- 'Content-Type': 'application/json',
202
- 'x-hq-client-name': HQ_CLIENT_NAME,
203
- // US-003: version alongside the client family, mirroring the desktop
204
- // app's client_info.rs headers, so the server can see client-version
205
- // skew on EVERY authenticated request, not only health heartbeats.
206
- // Fixed build constant, never caller-supplied; attribution only.
207
- 'x-hq-client-version': CLI_VERSION,
208
- ...opts.headers,
209
- },
210
- body: opts.body ? JSON.stringify(opts.body) : undefined,
211
- signal: opts.signal,
212
- });
213
- }
214
- catch (err) {
215
- // A transport failure never reaches the server, so the non-2xx breadcrumb
216
- // below never fires and the run's last HTTP trace is the request that
217
- // silently vanished (exactly what made HQ-CLI-G's event unreadable). Leave
218
- // a bounded, non-secret trail — the already-redacted safeUrl plus the
219
- // recognized transport code — then RE-THROW the original error untouched,
220
- // so `main.ts`'s classifier stays the single decision point.
221
- const code = networkTransportErrorCode(err);
222
- Sentry.addBreadcrumb({
223
- category: "http",
224
- message: `${method} ${path} → transport error`,
225
- level: "warning",
226
- data: { url: safeUrl, method, ...(code ? { code } : {}) },
227
- });
228
- throw err;
229
- }
246
+ const requestInit = {
247
+ method,
248
+ headers: {
249
+ Authorization: `Bearer ${opts.token}`,
250
+ 'Content-Type': 'application/json',
251
+ 'x-hq-client-name': HQ_CLIENT_NAME,
252
+ // US-003: version alongside the client family, mirroring the desktop
253
+ // app's client_info.rs headers, so the server can see client-version
254
+ // skew on EVERY authenticated request, not only health heartbeats.
255
+ // Fixed build constant, never caller-supplied; attribution only.
256
+ 'x-hq-client-version': CLI_VERSION,
257
+ ...opts.headers,
258
+ },
259
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
260
+ signal: opts.signal,
261
+ };
262
+ const fetchWithTransportBreadcrumb = async () => {
263
+ try {
264
+ return await fetch(url.toString(), requestInit);
265
+ }
266
+ catch (err) {
267
+ // Keep the original transport failure and its bounded, redacted trail on
268
+ // both the first request and the single retry.
269
+ const code = networkTransportErrorCode(err);
270
+ Sentry.addBreadcrumb({
271
+ category: "http",
272
+ message: `${method} ${path} → transport error`,
273
+ level: "warning",
274
+ data: { url: safeUrl, method, ...(code ? { code } : {}) },
275
+ });
276
+ throw err;
277
+ }
278
+ };
279
+ const response = await fetchWithLambdaInvokeRetry(fetchWithTransportBreadcrumb, method.toUpperCase() === 'GET' && opts.body === undefined, opts.signal);
230
280
  if (!response.ok) {
231
281
  Sentry.addBreadcrumb({
232
282
  category: "http",
@@ -287,10 +337,10 @@ export async function vaultApiFetchPublic(opts) {
287
337
  level: 'info',
288
338
  data: { url: safeUrl, method: 'GET' },
289
339
  });
290
- const response = await fetch(url.toString(), {
340
+ const response = await fetchWithLambdaInvokeRetry(() => fetch(url.toString(), {
291
341
  method: 'GET',
292
342
  headers: { 'Content-Type': 'application/json' },
293
- });
343
+ }), true);
294
344
  if (!response.ok) {
295
345
  Sentry.addBreadcrumb({
296
346
  category: 'http',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.321.0",
3
+ "version": "5.323.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {