@indigoai-us/hq-cli 5.10.1 → 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.
- package/CHANGELOG.md +48 -0
- package/dist/cli-version.d.ts +2 -0
- package/dist/cli-version.js +5 -0
- package/dist/commands/cloud.js +42 -3
- package/dist/commands/feedback.d.ts +16 -0
- package/dist/commands/feedback.js +98 -0
- package/dist/commands/secrets.d.ts +2 -2
- package/dist/commands/secrets.js +33 -25
- package/dist/index.js +12 -3
- package/dist/sentry.js +4 -2
- package/dist/utils/breadcrumb-buffer.d.ts +4 -0
- package/dist/utils/breadcrumb-buffer.js +18 -0
- package/dist/utils/feedback-diagnostics.d.ts +22 -0
- package/dist/utils/feedback-diagnostics.js +95 -0
- package/dist/utils/vault-api.d.ts +5 -0
- package/dist/utils/vault-api.js +55 -4
- package/package.json +2 -1
- package/src/cli-version.ts +1 -0
- package/src/commands/cloud.ts +40 -0
- package/src/commands/feedback.test.ts +369 -0
- package/src/commands/feedback.ts +136 -0
- package/src/commands/secrets.ts +86 -23
- package/src/index.ts +11 -1
- package/src/sentry.ts +2 -0
- package/src/utils/breadcrumb-buffer.ts +18 -0
- package/src/utils/feedback-diagnostics.test.ts +172 -0
- package/src/utils/feedback-diagnostics.ts +115 -0
- package/src/utils/vault-api.test.ts +147 -0
- package/src/utils/vault-api.ts +63 -2
package/src/commands/secrets.ts
CHANGED
|
@@ -10,9 +10,40 @@ import {
|
|
|
10
10
|
clearAllCache,
|
|
11
11
|
} from "../utils/secrets-cache.js";
|
|
12
12
|
import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN } from "./_patterns.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
vaultApiFetch,
|
|
15
|
+
getCompanyUid,
|
|
16
|
+
getEntityUid,
|
|
17
|
+
} from "../utils/vault-api.js";
|
|
14
18
|
export type { VaultApiOptions } from "../utils/vault-api.js";
|
|
15
|
-
export { vaultApiFetch, getCompanyUid };
|
|
19
|
+
export { vaultApiFetch, getCompanyUid, getEntityUid };
|
|
20
|
+
|
|
21
|
+
interface SecretsScopeOpts {
|
|
22
|
+
company?: string;
|
|
23
|
+
personal?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scopeOpts(opts: SecretsScopeOpts): {
|
|
27
|
+
personal: boolean;
|
|
28
|
+
companySlug: string | undefined;
|
|
29
|
+
} {
|
|
30
|
+
if (opts.personal && opts.company) {
|
|
31
|
+
console.error(
|
|
32
|
+
chalk.red("Error: --personal cannot be combined with --company."),
|
|
33
|
+
);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
return { personal: !!opts.personal, companySlug: opts.company };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function rejectIfPersonal(opts: SecretsScopeOpts, action: string): void {
|
|
40
|
+
if (opts.personal) {
|
|
41
|
+
console.error(
|
|
42
|
+
chalk.red(`Error: ${action} is not supported with --personal.`),
|
|
43
|
+
);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
16
47
|
|
|
17
48
|
function shellSingleQuote(value: string): string {
|
|
18
49
|
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
@@ -125,7 +156,11 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
125
156
|
const secrets = program
|
|
126
157
|
.command("secrets")
|
|
127
158
|
.description("Manage secrets in HQ vault (SSM Parameter Store)")
|
|
128
|
-
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
159
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
160
|
+
.option(
|
|
161
|
+
"--personal",
|
|
162
|
+
"Operate on the caller's personal vault (no sharing)",
|
|
163
|
+
);
|
|
129
164
|
|
|
130
165
|
secrets
|
|
131
166
|
.command("set <name>")
|
|
@@ -164,8 +199,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
164
199
|
}
|
|
165
200
|
|
|
166
201
|
const token = await ensureCognitoToken();
|
|
167
|
-
const
|
|
168
|
-
|
|
202
|
+
const companyUid = await getEntityUid(
|
|
203
|
+
token,
|
|
204
|
+
scopeOpts(secrets.opts()),
|
|
205
|
+
);
|
|
169
206
|
|
|
170
207
|
const res = await vaultApiFetch({
|
|
171
208
|
token,
|
|
@@ -200,8 +237,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
200
237
|
.action(async (name: string, opts: { reveal?: boolean }) => {
|
|
201
238
|
try {
|
|
202
239
|
const token = await ensureCognitoToken();
|
|
203
|
-
const
|
|
204
|
-
|
|
240
|
+
const companyUid = await getEntityUid(
|
|
241
|
+
token,
|
|
242
|
+
scopeOpts(secrets.opts()),
|
|
243
|
+
);
|
|
205
244
|
|
|
206
245
|
const query: Record<string, string> = {};
|
|
207
246
|
if (opts.reveal) {
|
|
@@ -283,8 +322,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
283
322
|
}
|
|
284
323
|
|
|
285
324
|
const token = await ensureCognitoToken();
|
|
286
|
-
const
|
|
287
|
-
|
|
325
|
+
const companyUid = await getEntityUid(
|
|
326
|
+
token,
|
|
327
|
+
scopeOpts(secrets.opts()),
|
|
328
|
+
);
|
|
288
329
|
|
|
289
330
|
const query: Record<string, string> = {};
|
|
290
331
|
if (normalizedPrefix) {
|
|
@@ -364,8 +405,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
364
405
|
}
|
|
365
406
|
|
|
366
407
|
const token = await ensureCognitoToken();
|
|
367
|
-
const
|
|
368
|
-
|
|
408
|
+
const companyUid = await getEntityUid(
|
|
409
|
+
token,
|
|
410
|
+
scopeOpts(secrets.opts()),
|
|
411
|
+
);
|
|
369
412
|
|
|
370
413
|
const res = await vaultApiFetch({
|
|
371
414
|
token,
|
|
@@ -427,8 +470,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
427
470
|
}
|
|
428
471
|
|
|
429
472
|
const token = await ensureCognitoToken();
|
|
430
|
-
const
|
|
431
|
-
|
|
473
|
+
const companyUid = await getEntityUid(
|
|
474
|
+
token,
|
|
475
|
+
scopeOpts(secrets.opts()),
|
|
476
|
+
);
|
|
432
477
|
|
|
433
478
|
const revealed = await Promise.all(
|
|
434
479
|
keys.map(async (key) => {
|
|
@@ -518,8 +563,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
518
563
|
}
|
|
519
564
|
|
|
520
565
|
const token = await ensureCognitoToken();
|
|
521
|
-
const
|
|
522
|
-
|
|
566
|
+
const companyUid = await getEntityUid(
|
|
567
|
+
token,
|
|
568
|
+
scopeOpts(secrets.opts()),
|
|
569
|
+
);
|
|
523
570
|
|
|
524
571
|
const revealed = await Promise.all(
|
|
525
572
|
keys.map(async (key) => {
|
|
@@ -568,6 +615,8 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
568
615
|
.option("--expires <duration>", "Token expiry duration (e.g. 24h, 2d, 30m)", "24h")
|
|
569
616
|
.action(async (name: string, opts: { expires: string }) => {
|
|
570
617
|
try {
|
|
618
|
+
rejectIfPersonal(secrets.opts(), "generate-link");
|
|
619
|
+
|
|
571
620
|
if (!SECRET_NAME_PATTERN.test(name)) {
|
|
572
621
|
console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_API_KEY or DEV/MY_KEY)`));
|
|
573
622
|
process.exit(1);
|
|
@@ -586,8 +635,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
586
635
|
}
|
|
587
636
|
|
|
588
637
|
const token = await ensureCognitoToken();
|
|
589
|
-
const
|
|
590
|
-
|
|
638
|
+
const companyUid = await getEntityUid(
|
|
639
|
+
token,
|
|
640
|
+
scopeOpts(secrets.opts()),
|
|
641
|
+
);
|
|
591
642
|
|
|
592
643
|
const res = await vaultApiFetch({
|
|
593
644
|
token,
|
|
@@ -632,6 +683,8 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
632
683
|
.requiredOption("--permission <level>", "Permission level: read | write | admin")
|
|
633
684
|
.action(async (path: string, opts: { with: string; permission: string }) => {
|
|
634
685
|
try {
|
|
686
|
+
rejectIfPersonal(secrets.opts(), "share");
|
|
687
|
+
|
|
635
688
|
if (!SECRET_NAME_PATTERN.test(path)) {
|
|
636
689
|
console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
637
690
|
process.exit(1);
|
|
@@ -651,8 +704,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
651
704
|
const granteeId = opts.with;
|
|
652
705
|
|
|
653
706
|
const token = await ensureCognitoToken();
|
|
654
|
-
const
|
|
655
|
-
|
|
707
|
+
const companyUid = await getEntityUid(
|
|
708
|
+
token,
|
|
709
|
+
scopeOpts(secrets.opts()),
|
|
710
|
+
);
|
|
656
711
|
|
|
657
712
|
const res = await vaultApiFetch({
|
|
658
713
|
token,
|
|
@@ -695,6 +750,8 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
695
750
|
.requiredOption("--from <principal>", "Email address or group id to remove")
|
|
696
751
|
.action(async (path: string, opts: { from: string }) => {
|
|
697
752
|
try {
|
|
753
|
+
rejectIfPersonal(secrets.opts(), "unshare");
|
|
754
|
+
|
|
698
755
|
if (!SECRET_NAME_PATTERN.test(path)) {
|
|
699
756
|
console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
700
757
|
process.exit(1);
|
|
@@ -709,8 +766,10 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
709
766
|
const granteeId = opts.from;
|
|
710
767
|
|
|
711
768
|
const token = await ensureCognitoToken();
|
|
712
|
-
const
|
|
713
|
-
|
|
769
|
+
const companyUid = await getEntityUid(
|
|
770
|
+
token,
|
|
771
|
+
scopeOpts(secrets.opts()),
|
|
772
|
+
);
|
|
714
773
|
|
|
715
774
|
const res = await vaultApiFetch({
|
|
716
775
|
token,
|
|
@@ -751,14 +810,18 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
751
810
|
.description("Show the ACL (access control list) for a secret path")
|
|
752
811
|
.action(async (path: string) => {
|
|
753
812
|
try {
|
|
813
|
+
rejectIfPersonal(secrets.opts(), "acl");
|
|
814
|
+
|
|
754
815
|
if (!SECRET_NAME_PATTERN.test(path)) {
|
|
755
816
|
console.error(chalk.red(`Invalid secret path '${path}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_KEY or PROD/DB_PASSWORD)`));
|
|
756
817
|
process.exit(1);
|
|
757
818
|
}
|
|
758
819
|
|
|
759
820
|
const token = await ensureCognitoToken();
|
|
760
|
-
const
|
|
761
|
-
|
|
821
|
+
const companyUid = await getEntityUid(
|
|
822
|
+
token,
|
|
823
|
+
scopeOpts(secrets.opts()),
|
|
824
|
+
);
|
|
762
825
|
|
|
763
826
|
const secretPath = path;
|
|
764
827
|
const res = await vaultApiFetch({
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,8 @@ import { registerRunCommand } from "./commands/run.js";
|
|
|
28
28
|
import { registerGroupsCommand } from "./commands/groups.js";
|
|
29
29
|
import { registerFilesCommand } from "./commands/files.js";
|
|
30
30
|
import { registerMembersCommand } from "./commands/members.js";
|
|
31
|
+
import { registerFeedbackCommand } from "./commands/feedback.js";
|
|
32
|
+
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
31
33
|
|
|
32
34
|
initSentry();
|
|
33
35
|
|
|
@@ -36,7 +38,7 @@ const program = new Command();
|
|
|
36
38
|
program
|
|
37
39
|
.name("hq")
|
|
38
40
|
.description("HQ management CLI — modules, packages, and cloud sync")
|
|
39
|
-
.version("5.
|
|
41
|
+
.version("5.12.0");
|
|
40
42
|
|
|
41
43
|
// Module management subcommand group
|
|
42
44
|
const modulesCmd = program
|
|
@@ -109,8 +111,16 @@ registerMembersCommand(program);
|
|
|
109
111
|
// Onboarding (top-level — Cognito + vault-service provisioning)
|
|
110
112
|
registerOnboardCommand(program);
|
|
111
113
|
|
|
114
|
+
// Feedback (subcommand group — hq feedback bug|feature)
|
|
115
|
+
registerFeedbackCommand(program);
|
|
116
|
+
|
|
112
117
|
(async () => {
|
|
113
118
|
try {
|
|
119
|
+
Sentry.addBreadcrumb({
|
|
120
|
+
category: "command",
|
|
121
|
+
message: sanitizeArgv(process.argv.slice(2)).join(" "),
|
|
122
|
+
level: "info",
|
|
123
|
+
});
|
|
114
124
|
await program.parseAsync();
|
|
115
125
|
} catch (err) {
|
|
116
126
|
Sentry.captureException(err);
|
package/src/sentry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as Sentry from "@sentry/node";
|
|
2
2
|
import { BUNDLED_DSN } from "./sentry-dsn.generated.js";
|
|
3
3
|
import { beforeSend } from "./sentry-before-send.js";
|
|
4
|
+
import { beforeBreadcrumb } from "./utils/breadcrumb-buffer.js";
|
|
4
5
|
|
|
5
6
|
export function initSentry(): void {
|
|
6
7
|
const dsn = BUNDLED_DSN || process.env.SENTRY_DSN;
|
|
@@ -13,6 +14,7 @@ export function initSentry(): void {
|
|
|
13
14
|
tags: { repo: "hq-cli" },
|
|
14
15
|
},
|
|
15
16
|
beforeSend,
|
|
17
|
+
beforeBreadcrumb,
|
|
16
18
|
});
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Breadcrumb } from "@sentry/node";
|
|
2
|
+
|
|
3
|
+
const BUFFER_SIZE = 20;
|
|
4
|
+
const _buffer: Breadcrumb[] = [];
|
|
5
|
+
|
|
6
|
+
// Sentry beforeBreadcrumb hook: records every breadcrumb in a ring buffer
|
|
7
|
+
// and returns it unchanged so Sentry still processes it normally.
|
|
8
|
+
export function beforeBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb | null {
|
|
9
|
+
_buffer.push(breadcrumb);
|
|
10
|
+
if (_buffer.length > BUFFER_SIZE) {
|
|
11
|
+
_buffer.shift();
|
|
12
|
+
}
|
|
13
|
+
return breadcrumb;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getRecentBreadcrumbs(): Breadcrumb[] {
|
|
17
|
+
return [..._buffer];
|
|
18
|
+
}
|
|
@@ -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
|
+
}
|