@indigoai-us/hq-cli 5.47.16 → 5.47.17
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/.github/workflows/publish.yml +2 -2
- package/dist/commands/feedback.d.ts +2 -0
- package/dist/commands/feedback.js +24 -2
- package/dist/commands/files.d.ts +19 -0
- package/dist/commands/files.js +37 -3
- package/dist/commands/secrets.js +25 -2
- package/dist/run/hq-plugin.js +9 -2
- package/dist/sentry-dsn.generated.d.ts +1 -1
- package/dist/sentry-dsn.generated.js +1 -1
- package/dist/utils/feedback-diagnostics.d.ts +7 -0
- package/dist/utils/feedback-diagnostics.js +4 -2
- package/dist/utils/feedback-screenshots.d.ts +23 -0
- package/dist/utils/feedback-screenshots.js +98 -0
- package/dist/utils/feedback-versions.d.ts +34 -0
- package/dist/utils/feedback-versions.js +50 -0
- package/package.json +1 -1
- package/src/commands/feedback.test.ts +44 -0
- package/src/commands/feedback.ts +46 -13
- package/src/commands/files-delete.test.ts +132 -0
- package/src/commands/files.ts +42 -1
- package/src/commands/secrets.test.ts +80 -0
- package/src/commands/secrets.ts +35 -0
- package/src/run/hq-plugin.test.ts +39 -0
- package/src/run/hq-plugin.ts +7 -0
- package/src/utils/feedback-diagnostics.test.ts +11 -0
- package/src/utils/feedback-diagnostics.ts +8 -0
- package/src/utils/feedback-screenshots.test.ts +134 -0
- package/src/utils/feedback-screenshots.ts +124 -0
- package/src/utils/feedback-versions.test.ts +98 -0
- package/src/utils/feedback-versions.ts +68 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
vi.mock("./vault-api.js", () => ({
|
|
7
|
+
vaultApiFetch: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { vaultApiFetch } from "./vault-api.js";
|
|
11
|
+
import {
|
|
12
|
+
MAX_SCREENSHOTS,
|
|
13
|
+
contentTypeForPath,
|
|
14
|
+
loadScreenshots,
|
|
15
|
+
uploadScreenshots,
|
|
16
|
+
} from "./feedback-screenshots.js";
|
|
17
|
+
|
|
18
|
+
let tmp: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "shots-"));
|
|
22
|
+
vi.mocked(vaultApiFetch).mockReset();
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function writePng(name: string, bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47])): string {
|
|
29
|
+
const p = path.join(tmp, name);
|
|
30
|
+
fs.writeFileSync(p, bytes);
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("contentTypeForPath", () => {
|
|
35
|
+
it("maps known image extensions (case-insensitive)", () => {
|
|
36
|
+
expect(contentTypeForPath("a.png")).toBe("image/png");
|
|
37
|
+
expect(contentTypeForPath("a.JPG")).toBe("image/jpeg");
|
|
38
|
+
expect(contentTypeForPath("a.jpeg")).toBe("image/jpeg");
|
|
39
|
+
expect(contentTypeForPath("a.webp")).toBe("image/webp");
|
|
40
|
+
expect(contentTypeForPath("a.gif")).toBe("image/gif");
|
|
41
|
+
});
|
|
42
|
+
it("rejects unsupported extensions", () => {
|
|
43
|
+
expect(() => contentTypeForPath("a.pdf")).toThrow(/unsupported screenshot type/);
|
|
44
|
+
expect(() => contentTypeForPath("a")).toThrow(/unsupported screenshot type/);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("loadScreenshots", () => {
|
|
49
|
+
it("reads valid images with their content types", () => {
|
|
50
|
+
const a = writePng("a.png");
|
|
51
|
+
const loaded = loadScreenshots([a]);
|
|
52
|
+
expect(loaded).toHaveLength(1);
|
|
53
|
+
expect(loaded[0].contentType).toBe("image/png");
|
|
54
|
+
expect(loaded[0].bytes.byteLength).toBeGreaterThan(0);
|
|
55
|
+
});
|
|
56
|
+
it("rejects more than MAX_SCREENSHOTS", () => {
|
|
57
|
+
const paths = Array.from({ length: MAX_SCREENSHOTS + 1 }, (_, i) => writePng(`s${i}.png`));
|
|
58
|
+
expect(() => loadScreenshots(paths)).toThrow(new RegExp(`at most ${MAX_SCREENSHOTS}`));
|
|
59
|
+
});
|
|
60
|
+
it("rejects a missing file", () => {
|
|
61
|
+
expect(() => loadScreenshots([path.join(tmp, "nope.png")])).toThrow(/cannot read screenshot/);
|
|
62
|
+
});
|
|
63
|
+
it("rejects an empty file", () => {
|
|
64
|
+
expect(() => loadScreenshots([writePng("empty.png", Buffer.alloc(0))])).toThrow(/empty/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("uploadScreenshots", () => {
|
|
69
|
+
it("returns [] without calling the API when no paths given", async () => {
|
|
70
|
+
const keys = await uploadScreenshots({ paths: [], token: "t" });
|
|
71
|
+
expect(keys).toEqual([]);
|
|
72
|
+
expect(vaultApiFetch).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("presigns, PUTs each image to S3, and returns the keys", async () => {
|
|
76
|
+
const a = writePng("a.png");
|
|
77
|
+
const b = writePng("b.jpg");
|
|
78
|
+
vi.mocked(vaultApiFetch).mockResolvedValue({
|
|
79
|
+
ok: true,
|
|
80
|
+
json: async () => ({
|
|
81
|
+
screenshots: [
|
|
82
|
+
{ key: "feedback-screenshots/prs_a/sub/0.png", url: "https://s3/put0", contentType: "image/png" },
|
|
83
|
+
{ key: "feedback-screenshots/prs_a/sub/1.jpg", url: "https://s3/put1", contentType: "image/jpeg" },
|
|
84
|
+
],
|
|
85
|
+
}),
|
|
86
|
+
} as unknown as Response);
|
|
87
|
+
const putCalls: Array<{ url: string; method?: string; contentType?: unknown }> = [];
|
|
88
|
+
const fetchImpl = vi.fn(async (url: string, init: { method?: string; headers?: Record<string, string> }) => {
|
|
89
|
+
putCalls.push({ url, method: init.method, contentType: init.headers?.["Content-Type"] });
|
|
90
|
+
return { ok: true, status: 200 } as Response;
|
|
91
|
+
}) as unknown as typeof fetch;
|
|
92
|
+
|
|
93
|
+
const keys = await uploadScreenshots({ paths: [a, b], token: "tok", fetchImpl });
|
|
94
|
+
|
|
95
|
+
expect(keys).toEqual([
|
|
96
|
+
"feedback-screenshots/prs_a/sub/0.png",
|
|
97
|
+
"feedback-screenshots/prs_a/sub/1.jpg",
|
|
98
|
+
]);
|
|
99
|
+
// Presign request asked for the right content types.
|
|
100
|
+
const presignBody = vi.mocked(vaultApiFetch).mock.calls[0][0].body;
|
|
101
|
+
expect(presignBody).toEqual({ contentTypes: ["image/png", "image/jpeg"] });
|
|
102
|
+
// One PUT per image, to the presigned URL, with the right Content-Type.
|
|
103
|
+
expect(putCalls).toEqual([
|
|
104
|
+
{ url: "https://s3/put0", method: "PUT", contentType: "image/png" },
|
|
105
|
+
{ url: "https://s3/put1", method: "PUT", contentType: "image/jpeg" },
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("throws when the presign call fails", async () => {
|
|
110
|
+
const a = writePng("a.png");
|
|
111
|
+
vi.mocked(vaultApiFetch).mockResolvedValue({
|
|
112
|
+
ok: false,
|
|
113
|
+
statusText: "Bad Request",
|
|
114
|
+
json: async () => ({ error: "at most 5 screenshots are allowed" }),
|
|
115
|
+
} as unknown as Response);
|
|
116
|
+
await expect(uploadScreenshots({ paths: [a], token: "t" })).rejects.toThrow(
|
|
117
|
+
/Failed to presign screenshots: at most 5/,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("throws when an S3 upload fails", async () => {
|
|
122
|
+
const a = writePng("a.png");
|
|
123
|
+
vi.mocked(vaultApiFetch).mockResolvedValue({
|
|
124
|
+
ok: true,
|
|
125
|
+
json: async () => ({
|
|
126
|
+
screenshots: [{ key: "feedback-screenshots/prs_a/sub/0.png", url: "https://s3/put0", contentType: "image/png" }],
|
|
127
|
+
}),
|
|
128
|
+
} as unknown as Response);
|
|
129
|
+
const fetchImpl = vi.fn(async () => ({ ok: false, status: 403 }) as Response) as unknown as typeof fetch;
|
|
130
|
+
await expect(uploadScreenshots({ paths: [a], token: "t", fetchImpl })).rejects.toThrow(
|
|
131
|
+
/Failed to upload screenshot .*: HTTP 403/,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { vaultApiFetch } from "./vault-api.js";
|
|
4
|
+
|
|
5
|
+
export const MAX_SCREENSHOTS = 5;
|
|
6
|
+
// Per-image ceiling. Screenshots are PNG/JPEG captures; 10 MB is generous.
|
|
7
|
+
export const MAX_SCREENSHOT_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
// Extension → content type. Must stay in sync with the server's allowed set
|
|
10
|
+
// (hq-pro feedback-screenshots.ts ALLOWED_CONTENT_TYPES).
|
|
11
|
+
const EXT_CONTENT_TYPE: Record<string, string> = {
|
|
12
|
+
".png": "image/png",
|
|
13
|
+
".jpg": "image/jpeg",
|
|
14
|
+
".jpeg": "image/jpeg",
|
|
15
|
+
".webp": "image/webp",
|
|
16
|
+
".gif": "image/gif",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function contentTypeForPath(filePath: string): string {
|
|
20
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
21
|
+
const contentType = EXT_CONTENT_TYPE[ext];
|
|
22
|
+
if (!contentType) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
`unsupported screenshot type: ${filePath} (allowed: ${Object.keys(EXT_CONTENT_TYPE).join(", ")})`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return contentType;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ScreenshotInput {
|
|
31
|
+
path: string;
|
|
32
|
+
contentType: string;
|
|
33
|
+
bytes: Buffer;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Validate + read the given screenshot paths (count, type, existence, size). */
|
|
37
|
+
export function loadScreenshots(paths: string[]): ScreenshotInput[] {
|
|
38
|
+
if (paths.length > MAX_SCREENSHOTS) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`at most ${MAX_SCREENSHOTS} screenshots are allowed (got ${paths.length})`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return paths.map((filePath) => {
|
|
44
|
+
const contentType = contentTypeForPath(filePath);
|
|
45
|
+
let bytes: Buffer;
|
|
46
|
+
try {
|
|
47
|
+
bytes = fs.readFileSync(filePath);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error(`cannot read screenshot: ${filePath}`);
|
|
50
|
+
}
|
|
51
|
+
if (bytes.byteLength === 0) {
|
|
52
|
+
throw new Error(`screenshot is empty: ${filePath}`);
|
|
53
|
+
}
|
|
54
|
+
if (bytes.byteLength > MAX_SCREENSHOT_BYTES) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`screenshot too large: ${filePath} (${bytes.byteLength} bytes, max ${MAX_SCREENSHOT_BYTES})`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return { path: filePath, contentType, bytes };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface PresignResponse {
|
|
64
|
+
screenshots: Array<{ key: string; url: string; contentType: string }>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate the screenshot paths, request presigned PUT URLs from the feedback
|
|
69
|
+
* endpoint, upload each image direct to S3, and return the object keys to
|
|
70
|
+
* attach to the feedback submission. Returns [] for no screenshots.
|
|
71
|
+
*
|
|
72
|
+
* `fetchImpl` is injectable for tests; defaults to the global fetch.
|
|
73
|
+
*/
|
|
74
|
+
export async function uploadScreenshots(opts: {
|
|
75
|
+
paths: string[];
|
|
76
|
+
token: string;
|
|
77
|
+
fetchImpl?: typeof fetch;
|
|
78
|
+
}): Promise<string[]> {
|
|
79
|
+
if (opts.paths.length === 0) return [];
|
|
80
|
+
const inputs = loadScreenshots(opts.paths);
|
|
81
|
+
|
|
82
|
+
const res = await vaultApiFetch({
|
|
83
|
+
token: opts.token,
|
|
84
|
+
path: "/v1/feedback/screenshots/presign",
|
|
85
|
+
method: "POST",
|
|
86
|
+
body: { contentTypes: inputs.map((i) => i.contentType) },
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
const data = await res.json().catch(() => ({}));
|
|
90
|
+
const msg =
|
|
91
|
+
data &&
|
|
92
|
+
typeof data === "object" &&
|
|
93
|
+
typeof (data as { error?: unknown }).error === "string"
|
|
94
|
+
? (data as { error: string }).error
|
|
95
|
+
: res.statusText;
|
|
96
|
+
throw new Error(`Failed to presign screenshots: ${msg}`);
|
|
97
|
+
}
|
|
98
|
+
const parsed = (await res.json()) as PresignResponse;
|
|
99
|
+
if (
|
|
100
|
+
!parsed ||
|
|
101
|
+
!Array.isArray(parsed.screenshots) ||
|
|
102
|
+
parsed.screenshots.length !== inputs.length
|
|
103
|
+
) {
|
|
104
|
+
throw new Error("Presign response did not match the requested screenshots");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
108
|
+
const keys: string[] = [];
|
|
109
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
110
|
+
const slot = parsed.screenshots[i]!;
|
|
111
|
+
const put = await doFetch(slot.url, {
|
|
112
|
+
method: "PUT",
|
|
113
|
+
headers: { "Content-Type": slot.contentType },
|
|
114
|
+
body: inputs[i]!.bytes,
|
|
115
|
+
});
|
|
116
|
+
if (!put.ok) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Failed to upload screenshot ${inputs[i]!.path}: HTTP ${put.status}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
keys.push(slot.key);
|
|
122
|
+
}
|
|
123
|
+
return keys;
|
|
124
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
vi.mock("./manifest.js", () => ({
|
|
7
|
+
findHqRoot: vi.fn(() => "/fake/hq"),
|
|
8
|
+
}));
|
|
9
|
+
vi.mock("./pack-contributions.js", () => ({
|
|
10
|
+
readHqVersion: vi.fn(() => "15.0.20"),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { findHqRoot } from "./manifest.js";
|
|
14
|
+
import { readHqVersion } from "./pack-contributions.js";
|
|
15
|
+
import { CLI_VERSION } from "../cli-version.js";
|
|
16
|
+
import {
|
|
17
|
+
collectVersions,
|
|
18
|
+
readCoreVersion,
|
|
19
|
+
readSyncVersion,
|
|
20
|
+
} from "./feedback-versions.js";
|
|
21
|
+
|
|
22
|
+
describe("readSyncVersion", () => {
|
|
23
|
+
let tmpHome: string;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "hqsync-"));
|
|
27
|
+
fs.mkdirSync(path.join(tmpHome, ".hq"), { recursive: true });
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function writeMarker(contents: string): void {
|
|
34
|
+
fs.writeFileSync(path.join(tmpHome, ".hq", "sync-version.json"), contents);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it("reads the version the hq-sync app records", () => {
|
|
38
|
+
writeMarker(JSON.stringify({ version: "0.8.22-beta.1", updatedAt: "x" }));
|
|
39
|
+
expect(readSyncVersion(tmpHome)).toBe("0.8.22-beta.1");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns null when the marker file is absent (hq-sync not installed)", () => {
|
|
43
|
+
expect(readSyncVersion(tmpHome)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns null on malformed JSON rather than throwing", () => {
|
|
47
|
+
writeMarker("{ not json");
|
|
48
|
+
expect(readSyncVersion(tmpHome)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns null when version is missing or not a non-empty string", () => {
|
|
52
|
+
writeMarker(JSON.stringify({ updatedAt: "x" }));
|
|
53
|
+
expect(readSyncVersion(tmpHome)).toBeNull();
|
|
54
|
+
writeMarker(JSON.stringify({ version: "" }));
|
|
55
|
+
expect(readSyncVersion(tmpHome)).toBeNull();
|
|
56
|
+
writeMarker(JSON.stringify({ version: 123 }));
|
|
57
|
+
expect(readSyncVersion(tmpHome)).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("readCoreVersion", () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.mocked(findHqRoot).mockReset().mockReturnValue("/fake/hq");
|
|
64
|
+
vi.mocked(readHqVersion).mockReset().mockReturnValue("15.0.20");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("reads hqVersion from the resolved HQ root", () => {
|
|
68
|
+
expect(readCoreVersion()).toBe("15.0.20");
|
|
69
|
+
expect(readHqVersion).toHaveBeenCalledWith("/fake/hq");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns null (never throws) when resolution fails", () => {
|
|
73
|
+
vi.mocked(findHqRoot).mockImplementation(() => {
|
|
74
|
+
throw new Error("no hq root");
|
|
75
|
+
});
|
|
76
|
+
expect(readCoreVersion()).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns null when core.yaml has no hqVersion", () => {
|
|
80
|
+
vi.mocked(readHqVersion).mockReturnValue(null);
|
|
81
|
+
expect(readCoreVersion()).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("collectVersions", () => {
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
vi.mocked(findHqRoot).mockReset().mockReturnValue("/fake/hq");
|
|
88
|
+
vi.mocked(readHqVersion).mockReset().mockReturnValue("15.0.20");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("always reports the cli version and best-effort core/sync", () => {
|
|
92
|
+
const versions = collectVersions();
|
|
93
|
+
expect(versions.cli).toBe(CLI_VERSION);
|
|
94
|
+
expect(versions.core).toBe("15.0.20");
|
|
95
|
+
// sync reads the real homedir marker, which won't exist in CI → null.
|
|
96
|
+
expect(["string", "object"]).toContain(typeof versions.sync); // string | null
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { CLI_VERSION } from "../cli-version.js";
|
|
5
|
+
import { findHqRoot } from "./manifest.js";
|
|
6
|
+
import { readHqVersion } from "./pack-contributions.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The three HQ component versions captured from the submitter's environment
|
|
10
|
+
* and attached to a feedback submission so triage can see exactly which
|
|
11
|
+
* versions a report came from.
|
|
12
|
+
*
|
|
13
|
+
* - `cli` — this hq-cli build (always known).
|
|
14
|
+
* - `core` — the HQ scaffold version from `core/core.yaml` (`hqVersion`);
|
|
15
|
+
* null when the command runs outside an HQ tree.
|
|
16
|
+
* - `sync` — the installed hq-sync menubar app version, which the app
|
|
17
|
+
* records at `~/.hq/sync-version.json` on startup; null when
|
|
18
|
+
* hq-sync is not installed (e.g. CLI-only / CI environments).
|
|
19
|
+
*/
|
|
20
|
+
export interface VersionInfo {
|
|
21
|
+
cli: string;
|
|
22
|
+
core: string | null;
|
|
23
|
+
sync: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Best-effort read of the hq-core scaffold version (`core/core.yaml`
|
|
28
|
+
* `hqVersion`). Resolves the HQ root from the working directory; returns
|
|
29
|
+
* null when no HQ root / core.yaml is found rather than throwing — version
|
|
30
|
+
* capture must never break a feedback submission.
|
|
31
|
+
*/
|
|
32
|
+
export function readCoreVersion(): string | null {
|
|
33
|
+
try {
|
|
34
|
+
return readHqVersion(findHqRoot());
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Best-effort read of the hq-sync menubar app version. The app writes
|
|
42
|
+
* `{ version, updatedAt }` to `~/.hq/sync-version.json` on startup; the CLI
|
|
43
|
+
* reads it here. Returns null when the file is absent or malformed (hq-sync
|
|
44
|
+
* not installed, or an older build that predates the marker).
|
|
45
|
+
*/
|
|
46
|
+
export function readSyncVersion(homeDir: string = os.homedir()): string | null {
|
|
47
|
+
try {
|
|
48
|
+
const raw = fs.readFileSync(
|
|
49
|
+
path.join(homeDir, ".hq", "sync-version.json"),
|
|
50
|
+
"utf-8",
|
|
51
|
+
);
|
|
52
|
+
const parsed = JSON.parse(raw) as { version?: unknown };
|
|
53
|
+
return typeof parsed.version === "string" && parsed.version.length > 0
|
|
54
|
+
? parsed.version
|
|
55
|
+
: null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Collect all three component versions, each independently best-effort. */
|
|
62
|
+
export function collectVersions(): VersionInfo {
|
|
63
|
+
return {
|
|
64
|
+
cli: CLI_VERSION,
|
|
65
|
+
core: readCoreVersion(),
|
|
66
|
+
sync: readSyncVersion(),
|
|
67
|
+
};
|
|
68
|
+
}
|