@indigoai-us/hq-cli 5.11.0 → 5.12.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,172 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import * as os from "os";
3
+
4
+ vi.mock("child_process", () => ({
5
+ execFileSync: vi.fn(),
6
+ }));
7
+
8
+ vi.mock("./breadcrumb-buffer.js", () => ({
9
+ getRecentBreadcrumbs: vi.fn(() => []),
10
+ }));
11
+
12
+ import { execFileSync } from "child_process";
13
+ import { getRecentBreadcrumbs } from "./breadcrumb-buffer.js";
14
+ import { collectDiagnostics, sanitizeArgv } from "./feedback-diagnostics.js";
15
+ import { CLI_VERSION } from "../cli-version.js";
16
+
17
+ function mockGitSuccess(remoteUrl = "https://github.com/acme/repo.git"): void {
18
+ vi.mocked(execFileSync).mockImplementation(
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ (_cmd: string, args?: any): any => {
21
+ const a: string[] = args ?? [];
22
+ if (a[0] === "rev-parse" && a[1] === "--abbrev-ref") return "main\n";
23
+ if (a[0] === "rev-parse" && a[1] === "--short") return "abc1234\n";
24
+ if (a[0] === "status") return "";
25
+ if (a[0] === "remote") return `${remoteUrl}\n`;
26
+ return "";
27
+ },
28
+ );
29
+ }
30
+
31
+ beforeEach(() => {
32
+ vi.resetAllMocks();
33
+ vi.mocked(getRecentBreadcrumbs).mockReturnValue([]);
34
+ mockGitSuccess();
35
+ });
36
+
37
+ describe("sanitizeArgv", () => {
38
+ it("passes through non-secret args unchanged", () => {
39
+ expect(sanitizeArgv(["feedback", "bug", "--title", "test"])).toEqual([
40
+ "feedback",
41
+ "bug",
42
+ "--title",
43
+ "test",
44
+ ]);
45
+ });
46
+
47
+ it("redacts value following a secret flag", () => {
48
+ expect(sanitizeArgv(["secrets", "set", "NAME", "--token", "abc123"])).toEqual([
49
+ "secrets",
50
+ "set",
51
+ "NAME",
52
+ "--token",
53
+ "***",
54
+ ]);
55
+ });
56
+
57
+ it("redacts --password and --secret flags", () => {
58
+ expect(sanitizeArgv(["--password", "hunter2", "--secret", "s3cr3t"])).toEqual([
59
+ "--password",
60
+ "***",
61
+ "--secret",
62
+ "***",
63
+ ]);
64
+ });
65
+
66
+ it("does not redact a secret flag at the end with no following value", () => {
67
+ expect(sanitizeArgv(["--token"])).toEqual(["--token"]);
68
+ });
69
+
70
+ it("redacts value in --flag=value form for secret flags", () => {
71
+ expect(sanitizeArgv(["--token=abc123", "--password=hunter2"])).toEqual([
72
+ "--token=***",
73
+ "--password=***",
74
+ ]);
75
+ });
76
+
77
+ it("passes non-secret --flag=value forms through unchanged", () => {
78
+ expect(sanitizeArgv(["--title=foo", "--company=acme"])).toEqual([
79
+ "--title=foo",
80
+ "--company=acme",
81
+ ]);
82
+ });
83
+ });
84
+
85
+ describe("collectDiagnostics", () => {
86
+ it("populates cliVersion from the bundled CLI_VERSION constant (not env var)", () => {
87
+ const saved = process.env.npm_package_version;
88
+ delete process.env.npm_package_version;
89
+ const blob = collectDiagnostics();
90
+ expect(blob.cliVersion).toBe(CLI_VERSION);
91
+ if (saved !== undefined) process.env.npm_package_version = saved;
92
+ });
93
+
94
+ it("cliVersion is unaffected by npm_package_version env var", () => {
95
+ process.env.npm_package_version = "99.99.99";
96
+ const blob = collectDiagnostics();
97
+ expect(blob.cliVersion).toBe(CLI_VERSION);
98
+ delete process.env.npm_package_version;
99
+ });
100
+
101
+ it("sanitizes secret flags in the captured command", () => {
102
+ const saved = process.argv;
103
+ process.argv = ["node", "/usr/bin/hq", "secrets", "set", "NAME", "--token", "secret123"];
104
+ const blob = collectDiagnostics();
105
+ expect(blob.command).toEqual(["secrets", "set", "NAME", "--token", "***"]);
106
+ process.argv = saved;
107
+ });
108
+
109
+ it("sanitizes --flag=value secret flags in the captured command", () => {
110
+ const saved = process.argv;
111
+ process.argv = ["node", "/usr/bin/hq", "secrets", "set", "NAME", "--token=secretXYZ"];
112
+ const blob = collectDiagnostics();
113
+ expect(blob.command).toEqual(["secrets", "set", "NAME", "--token=***"]);
114
+ process.argv = saved;
115
+ });
116
+
117
+ it("captures command as process.argv slice from index 2", () => {
118
+ const saved = process.argv;
119
+ process.argv = ["node", "/usr/bin/hq", "feedback", "bug"];
120
+ const blob = collectDiagnostics();
121
+ expect(blob.command).toEqual(["feedback", "bug"]);
122
+ process.argv = saved;
123
+ });
124
+
125
+ it("populates nodeVersion and os fields from process and os module", () => {
126
+ const blob = collectDiagnostics();
127
+ expect(blob.nodeVersion).toBe(process.version);
128
+ expect(blob.os.platform).toBe(os.platform());
129
+ expect(blob.os.release).toBe(os.release());
130
+ expect(blob.os.arch).toBe(os.arch());
131
+ });
132
+
133
+ it("sanitizes credentials embedded in HTTPS remote URLs", () => {
134
+ mockGitSuccess("https://user:s3cr3t@github.com/acme/repo.git");
135
+ const blob = collectDiagnostics();
136
+ expect(blob.git.remoteUrl).toBe("https://***@github.com/acme/repo.git");
137
+ });
138
+
139
+ it("returns null git context when not inside a git repo", () => {
140
+ vi.mocked(execFileSync).mockImplementation(() => {
141
+ throw new Error("not a git repo");
142
+ });
143
+ const blob = collectDiagnostics();
144
+ expect(blob.git.branch).toBeNull();
145
+ expect(blob.git.head).toBeNull();
146
+ expect(blob.git.remoteUrl).toBeNull();
147
+ expect(blob.git.dirty).toBe(false);
148
+ });
149
+
150
+ it("includes recentSentryBreadcrumbs from the ring buffer", () => {
151
+ const crumb = { category: "http", message: "GET /v1/foo", level: "info" as const };
152
+ vi.mocked(getRecentBreadcrumbs).mockReturnValue([crumb]);
153
+ const blob = collectDiagnostics();
154
+ expect(blob.recentSentryBreadcrumbs).toEqual([crumb]);
155
+ });
156
+
157
+ it("reports dirty=true when git status shows uncommitted changes", () => {
158
+ vi.mocked(execFileSync).mockImplementation(
159
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
160
+ (_cmd: string, args?: any): any => {
161
+ const a: string[] = args ?? [];
162
+ if (a[0] === "rev-parse" && a[1] === "--abbrev-ref") return "main\n";
163
+ if (a[0] === "rev-parse" && a[1] === "--short") return "abc1234\n";
164
+ if (a[0] === "status") return " M src/index.ts\n";
165
+ if (a[0] === "remote") return "https://github.com/acme/repo.git\n";
166
+ return "";
167
+ },
168
+ );
169
+ const blob = collectDiagnostics();
170
+ expect(blob.git.dirty).toBe(true);
171
+ });
172
+ });
@@ -0,0 +1,115 @@
1
+ import * as os from "os";
2
+ import { execFileSync } from "child_process";
3
+ import { getRecentBreadcrumbs } from "./breadcrumb-buffer.js";
4
+ import { CLI_VERSION } from "../cli-version.js";
5
+
6
+ export interface GitContext {
7
+ branch: string | null;
8
+ head: string | null;
9
+ dirty: boolean;
10
+ remoteUrl: string | null;
11
+ }
12
+
13
+ export interface DiagnosticsBlob {
14
+ cliVersion: string;
15
+ nodeVersion: string;
16
+ os: { platform: string; release: string; arch: string };
17
+ command: string[];
18
+ cwd: string;
19
+ git: GitContext;
20
+ recentSentryBreadcrumbs: unknown[];
21
+ }
22
+
23
+ const SECRET_FLAGS = new Set([
24
+ "--token",
25
+ "--secret",
26
+ "--password",
27
+ "--key",
28
+ "--api-key",
29
+ "--access-token",
30
+ "--auth-token",
31
+ ]);
32
+
33
+ export function sanitizeArgv(argv: string[]): string[] {
34
+ const result: string[] = [];
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const arg = argv[i];
37
+ if (arg.startsWith("--") && arg.includes("=")) {
38
+ const eqIdx = arg.indexOf("=");
39
+ const flag = arg.slice(0, eqIdx);
40
+ if (SECRET_FLAGS.has(flag)) {
41
+ result.push(`${flag}=***`);
42
+ continue;
43
+ }
44
+ }
45
+ result.push(arg);
46
+ if (SECRET_FLAGS.has(arg) && i + 1 < argv.length) {
47
+ result.push("***");
48
+ i++;
49
+ }
50
+ }
51
+ return result;
52
+ }
53
+
54
+ function sanitizeRemoteUrl(url: string): string {
55
+ return url.replace(/https?:\/\/[^@]+@/, "https://***@");
56
+ }
57
+
58
+ function runGit(args: string[]): string {
59
+ return execFileSync("git", args, {
60
+ encoding: "utf-8",
61
+ timeout: 2000,
62
+ stdio: ["ignore", "pipe", "ignore"],
63
+ }).trim();
64
+ }
65
+
66
+ function collectGitContext(): GitContext {
67
+ let branch: string | null = null;
68
+ let head: string | null = null;
69
+ let dirty = false;
70
+ let remoteUrl: string | null = null;
71
+
72
+ try {
73
+ branch = runGit(["rev-parse", "--abbrev-ref", "HEAD"]);
74
+ } catch {
75
+ return { branch: null, head: null, dirty: false, remoteUrl: null };
76
+ }
77
+
78
+ try {
79
+ head = runGit(["rev-parse", "--short", "HEAD"]);
80
+ } catch {
81
+ // best-effort
82
+ }
83
+
84
+ try {
85
+ const statusOut = runGit(["status", "--porcelain"]);
86
+ dirty = statusOut.length > 0;
87
+ } catch {
88
+ // best-effort
89
+ }
90
+
91
+ try {
92
+ const raw = runGit(["remote", "get-url", "origin"]);
93
+ remoteUrl = sanitizeRemoteUrl(raw);
94
+ } catch {
95
+ // no origin remote
96
+ }
97
+
98
+ return { branch, head, dirty, remoteUrl };
99
+ }
100
+
101
+ export function collectDiagnostics(): DiagnosticsBlob {
102
+ return {
103
+ cliVersion: CLI_VERSION,
104
+ nodeVersion: process.version,
105
+ os: {
106
+ platform: os.platform(),
107
+ release: os.release(),
108
+ arch: os.arch(),
109
+ },
110
+ command: sanitizeArgv(process.argv.slice(2)),
111
+ cwd: process.cwd(),
112
+ git: collectGitContext(),
113
+ recentSentryBreadcrumbs: getRecentBreadcrumbs(),
114
+ };
115
+ }
@@ -1,6 +1,11 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
 
3
- import { getEntityUid, resolveCallerPersonUid } from './vault-api.js';
3
+ vi.mock('../sentry.js', () => ({
4
+ Sentry: { addBreadcrumb: vi.fn() },
5
+ }));
6
+
7
+ import { Sentry } from '../sentry.js';
8
+ import { getEntityUid, resolveCallerPersonUid, vaultApiFetch } from './vault-api.js';
4
9
 
5
10
  const fetchMock = vi.fn();
6
11
  const originalFetch = globalThis.fetch;
@@ -109,3 +114,34 @@ describe('getEntityUid', () => {
109
114
  expect(url).toMatch(/\/membership\/me/);
110
115
  });
111
116
  });
117
+
118
+ describe('vaultApiFetch breadcrumb URL sanitization', () => {
119
+ it('redacts query string in request breadcrumb data.url', async () => {
120
+ fetchMock.mockResolvedValueOnce(mockResponse(200, {}));
121
+ const addBreadcrumbMock = vi.mocked(Sentry.addBreadcrumb);
122
+ addBreadcrumbMock.mockClear();
123
+ await vaultApiFetch({ token: 'tok', path: '/v1/foo', query: { path: 'secrets/my-secret', reveal: 'true' } });
124
+ const requestCrumb = addBreadcrumbMock.mock.calls[0][0];
125
+ expect(requestCrumb.data?.url).not.toMatch(/secrets%2F|reveal=true/);
126
+ expect(requestCrumb.data?.url).toContain('?<redacted>');
127
+ });
128
+
129
+ it('omits query delimiter when there is no query string', async () => {
130
+ fetchMock.mockResolvedValueOnce(mockResponse(200, {}));
131
+ const addBreadcrumbMock = vi.mocked(Sentry.addBreadcrumb);
132
+ addBreadcrumbMock.mockClear();
133
+ await vaultApiFetch({ token: 'tok', path: '/v1/bar' });
134
+ const requestCrumb = addBreadcrumbMock.mock.calls[0][0];
135
+ expect(requestCrumb.data?.url).not.toContain('?');
136
+ });
137
+
138
+ it('redacts query string in non-2xx error breadcrumb data.url', async () => {
139
+ fetchMock.mockResolvedValueOnce(mockResponse(401, {}));
140
+ const addBreadcrumbMock = vi.mocked(Sentry.addBreadcrumb);
141
+ addBreadcrumbMock.mockClear();
142
+ await vaultApiFetch({ token: 'tok', path: '/v1/baz', query: { action: 'generate-token' } });
143
+ const errorCrumb = addBreadcrumbMock.mock.calls[1][0];
144
+ expect(errorCrumb.data?.url).not.toContain('generate-token');
145
+ expect(errorCrumb.data?.url).toContain('?<redacted>');
146
+ });
147
+ });
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
2
+ import { Sentry } from '../sentry.js';
2
3
 
3
4
  export interface VaultApiOptions {
4
5
  token: string;
@@ -15,14 +16,31 @@ export async function vaultApiFetch(opts: VaultApiOptions): Promise<Response> {
15
16
  url.searchParams.set(k, v);
16
17
  }
17
18
  }
18
- return fetch(url.toString(), {
19
- method: opts.method ?? 'GET',
19
+ const method = opts.method ?? 'GET';
20
+ const safeUrl = url.search ? `${url.origin}${url.pathname}?<redacted>` : `${url.origin}${url.pathname}`;
21
+ Sentry.addBreadcrumb({
22
+ category: "http",
23
+ message: `${method} ${opts.path}`,
24
+ level: "info",
25
+ data: { url: safeUrl, method },
26
+ });
27
+ const response = await fetch(url.toString(), {
28
+ method,
20
29
  headers: {
21
30
  Authorization: `Bearer ${opts.token}`,
22
31
  'Content-Type': 'application/json',
23
32
  },
24
33
  body: opts.body ? JSON.stringify(opts.body) : undefined,
25
34
  });
35
+ if (!response.ok) {
36
+ Sentry.addBreadcrumb({
37
+ category: "http",
38
+ message: `${method} ${opts.path} → ${response.status}`,
39
+ level: "warning",
40
+ data: { url: safeUrl, status: response.status },
41
+ });
42
+ }
43
+ return response;
26
44
  }
27
45
 
28
46
  interface MembershipEntry {