@indigoai-us/hq-cli 5.47.15 → 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/people.d.ts +22 -0
- package/dist/commands/people.js +187 -0
- package/dist/commands/secrets.js +25 -2
- package/dist/index.js +6 -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/dist/utils/people.d.ts +99 -0
- package/dist/utils/people.js +168 -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/people.test.ts +426 -0
- package/src/commands/people.ts +240 -0
- package/src/commands/secrets.test.ts +80 -0
- package/src/commands/secrets.ts +35 -0
- package/src/index.ts +4 -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
- package/src/utils/people.ts +222 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="13f46978-c3c8-532d-b0bb-e8febf876587")}catch(e){}}();
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { vaultApiFetch } from "./vault-api.js";
|
|
6
|
+
export const MAX_SCREENSHOTS = 5;
|
|
7
|
+
// Per-image ceiling. Screenshots are PNG/JPEG captures; 10 MB is generous.
|
|
8
|
+
export const MAX_SCREENSHOT_BYTES = 10 * 1024 * 1024;
|
|
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 = {
|
|
12
|
+
".png": "image/png",
|
|
13
|
+
".jpg": "image/jpeg",
|
|
14
|
+
".jpeg": "image/jpeg",
|
|
15
|
+
".webp": "image/webp",
|
|
16
|
+
".gif": "image/gif",
|
|
17
|
+
};
|
|
18
|
+
export function contentTypeForPath(filePath) {
|
|
19
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
20
|
+
const contentType = EXT_CONTENT_TYPE[ext];
|
|
21
|
+
if (!contentType) {
|
|
22
|
+
throw new Error(`unsupported screenshot type: ${filePath} (allowed: ${Object.keys(EXT_CONTENT_TYPE).join(", ")})`);
|
|
23
|
+
}
|
|
24
|
+
return contentType;
|
|
25
|
+
}
|
|
26
|
+
/** Validate + read the given screenshot paths (count, type, existence, size). */
|
|
27
|
+
export function loadScreenshots(paths) {
|
|
28
|
+
if (paths.length > MAX_SCREENSHOTS) {
|
|
29
|
+
throw new Error(`at most ${MAX_SCREENSHOTS} screenshots are allowed (got ${paths.length})`);
|
|
30
|
+
}
|
|
31
|
+
return paths.map((filePath) => {
|
|
32
|
+
const contentType = contentTypeForPath(filePath);
|
|
33
|
+
let bytes;
|
|
34
|
+
try {
|
|
35
|
+
bytes = fs.readFileSync(filePath);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new Error(`cannot read screenshot: ${filePath}`);
|
|
39
|
+
}
|
|
40
|
+
if (bytes.byteLength === 0) {
|
|
41
|
+
throw new Error(`screenshot is empty: ${filePath}`);
|
|
42
|
+
}
|
|
43
|
+
if (bytes.byteLength > MAX_SCREENSHOT_BYTES) {
|
|
44
|
+
throw new Error(`screenshot too large: ${filePath} (${bytes.byteLength} bytes, max ${MAX_SCREENSHOT_BYTES})`);
|
|
45
|
+
}
|
|
46
|
+
return { path: filePath, contentType, bytes };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Validate the screenshot paths, request presigned PUT URLs from the feedback
|
|
51
|
+
* endpoint, upload each image direct to S3, and return the object keys to
|
|
52
|
+
* attach to the feedback submission. Returns [] for no screenshots.
|
|
53
|
+
*
|
|
54
|
+
* `fetchImpl` is injectable for tests; defaults to the global fetch.
|
|
55
|
+
*/
|
|
56
|
+
export async function uploadScreenshots(opts) {
|
|
57
|
+
if (opts.paths.length === 0)
|
|
58
|
+
return [];
|
|
59
|
+
const inputs = loadScreenshots(opts.paths);
|
|
60
|
+
const res = await vaultApiFetch({
|
|
61
|
+
token: opts.token,
|
|
62
|
+
path: "/v1/feedback/screenshots/presign",
|
|
63
|
+
method: "POST",
|
|
64
|
+
body: { contentTypes: inputs.map((i) => i.contentType) },
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const data = await res.json().catch(() => ({}));
|
|
68
|
+
const msg = data &&
|
|
69
|
+
typeof data === "object" &&
|
|
70
|
+
typeof data.error === "string"
|
|
71
|
+
? data.error
|
|
72
|
+
: res.statusText;
|
|
73
|
+
throw new Error(`Failed to presign screenshots: ${msg}`);
|
|
74
|
+
}
|
|
75
|
+
const parsed = (await res.json());
|
|
76
|
+
if (!parsed ||
|
|
77
|
+
!Array.isArray(parsed.screenshots) ||
|
|
78
|
+
parsed.screenshots.length !== inputs.length) {
|
|
79
|
+
throw new Error("Presign response did not match the requested screenshots");
|
|
80
|
+
}
|
|
81
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
82
|
+
const keys = [];
|
|
83
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
84
|
+
const slot = parsed.screenshots[i];
|
|
85
|
+
const put = await doFetch(slot.url, {
|
|
86
|
+
method: "PUT",
|
|
87
|
+
headers: { "Content-Type": slot.contentType },
|
|
88
|
+
body: inputs[i].bytes,
|
|
89
|
+
});
|
|
90
|
+
if (!put.ok) {
|
|
91
|
+
throw new Error(`Failed to upload screenshot ${inputs[i].path}: HTTP ${put.status}`);
|
|
92
|
+
}
|
|
93
|
+
keys.push(slot.key);
|
|
94
|
+
}
|
|
95
|
+
return keys;
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=feedback-screenshots.js.map
|
|
98
|
+
//# debugId=13f46978-c3c8-532d-b0bb-e8febf876587
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three HQ component versions captured from the submitter's environment
|
|
3
|
+
* and attached to a feedback submission so triage can see exactly which
|
|
4
|
+
* versions a report came from.
|
|
5
|
+
*
|
|
6
|
+
* - `cli` — this hq-cli build (always known).
|
|
7
|
+
* - `core` — the HQ scaffold version from `core/core.yaml` (`hqVersion`);
|
|
8
|
+
* null when the command runs outside an HQ tree.
|
|
9
|
+
* - `sync` — the installed hq-sync menubar app version, which the app
|
|
10
|
+
* records at `~/.hq/sync-version.json` on startup; null when
|
|
11
|
+
* hq-sync is not installed (e.g. CLI-only / CI environments).
|
|
12
|
+
*/
|
|
13
|
+
export interface VersionInfo {
|
|
14
|
+
cli: string;
|
|
15
|
+
core: string | null;
|
|
16
|
+
sync: string | null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Best-effort read of the hq-core scaffold version (`core/core.yaml`
|
|
20
|
+
* `hqVersion`). Resolves the HQ root from the working directory; returns
|
|
21
|
+
* null when no HQ root / core.yaml is found rather than throwing — version
|
|
22
|
+
* capture must never break a feedback submission.
|
|
23
|
+
*/
|
|
24
|
+
export declare function readCoreVersion(): string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Best-effort read of the hq-sync menubar app version. The app writes
|
|
27
|
+
* `{ version, updatedAt }` to `~/.hq/sync-version.json` on startup; the CLI
|
|
28
|
+
* reads it here. Returns null when the file is absent or malformed (hq-sync
|
|
29
|
+
* not installed, or an older build that predates the marker).
|
|
30
|
+
*/
|
|
31
|
+
export declare function readSyncVersion(homeDir?: string): string | null;
|
|
32
|
+
/** Collect all three component versions, each independently best-effort. */
|
|
33
|
+
export declare function collectVersions(): VersionInfo;
|
|
34
|
+
//# sourceMappingURL=feedback-versions.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="ea3328d1-5c42-5d60-ad24-f96c11a37f22")}catch(e){}}();
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { CLI_VERSION } from "../cli-version.js";
|
|
7
|
+
import { findHqRoot } from "./manifest.js";
|
|
8
|
+
import { readHqVersion } from "./pack-contributions.js";
|
|
9
|
+
/**
|
|
10
|
+
* Best-effort read of the hq-core scaffold version (`core/core.yaml`
|
|
11
|
+
* `hqVersion`). Resolves the HQ root from the working directory; returns
|
|
12
|
+
* null when no HQ root / core.yaml is found rather than throwing — version
|
|
13
|
+
* capture must never break a feedback submission.
|
|
14
|
+
*/
|
|
15
|
+
export function readCoreVersion() {
|
|
16
|
+
try {
|
|
17
|
+
return readHqVersion(findHqRoot());
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Best-effort read of the hq-sync menubar app version. The app writes
|
|
25
|
+
* `{ version, updatedAt }` to `~/.hq/sync-version.json` on startup; the CLI
|
|
26
|
+
* reads it here. Returns null when the file is absent or malformed (hq-sync
|
|
27
|
+
* not installed, or an older build that predates the marker).
|
|
28
|
+
*/
|
|
29
|
+
export function readSyncVersion(homeDir = os.homedir()) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = fs.readFileSync(path.join(homeDir, ".hq", "sync-version.json"), "utf-8");
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
return typeof parsed.version === "string" && parsed.version.length > 0
|
|
34
|
+
? parsed.version
|
|
35
|
+
: null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Collect all three component versions, each independently best-effort. */
|
|
42
|
+
export function collectVersions() {
|
|
43
|
+
return {
|
|
44
|
+
cli: CLI_VERSION,
|
|
45
|
+
core: readCoreVersion(),
|
|
46
|
+
sync: readSyncVersion(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=feedback-versions.js.map
|
|
50
|
+
//# debugId=ea3328d1-5c42-5d60-ad24-f96c11a37f22
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Company people (membership) directory reader + search.
|
|
3
|
+
*
|
|
4
|
+
* The local source of truth for "who belongs to a company" is the per-company
|
|
5
|
+
* people store on disk:
|
|
6
|
+
*
|
|
7
|
+
* companies/<company-slug>/people/<person-slug>/meta.yaml
|
|
8
|
+
*
|
|
9
|
+
* Each `meta.yaml` carries at least `name` and `type` ("internal" | "external")
|
|
10
|
+
* plus optional `email`, `role`, `organization`, `tags`, etc. (see
|
|
11
|
+
* `companies/_template/people/_example/meta.yaml` for the canonical schema).
|
|
12
|
+
*
|
|
13
|
+
* Everything here is scoped to ONE company directory. Callers resolve a single
|
|
14
|
+
* company slug up front; nothing in this module ever enumerates or reads across
|
|
15
|
+
* company boundaries — HQ tenancy rules forbid cross-company member lookups.
|
|
16
|
+
*/
|
|
17
|
+
/** A single person/member record parsed from a `people/<slug>/meta.yaml`. */
|
|
18
|
+
export interface PersonRecord {
|
|
19
|
+
/** Folder slug under `companies/<company>/people/<slug>/`. */
|
|
20
|
+
slug: string;
|
|
21
|
+
/** Display name (required field in `meta.yaml`). */
|
|
22
|
+
name: string;
|
|
23
|
+
/** Contact email, when recorded. */
|
|
24
|
+
email?: string;
|
|
25
|
+
/** "internal" (team member) | "external" (client, vendor, …). */
|
|
26
|
+
type?: string;
|
|
27
|
+
/** Role/title inside (or relative to) the company. */
|
|
28
|
+
role?: string;
|
|
29
|
+
/** External-only: the person's own organization. */
|
|
30
|
+
organization?: string;
|
|
31
|
+
/** Freeform tags for filtering. */
|
|
32
|
+
tags?: string[];
|
|
33
|
+
/** Absolute path to the `meta.yaml` this record was parsed from. */
|
|
34
|
+
source: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function assertSafeCompanySlug(slug: string): void;
|
|
37
|
+
/** Absolute path to a company's `people/` directory. */
|
|
38
|
+
export declare function companyPeopleDir(hqRoot: string, companySlug: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Parse a single `meta.yaml` body into a `PersonRecord`. Returns `null` when the
|
|
41
|
+
* file is empty, unparseable, or has no usable `name` — a person record without
|
|
42
|
+
* a name can't be listed, searched, or resolved, so it's skipped rather than
|
|
43
|
+
* surfaced as a half-row. Exported for unit testing.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parsePersonMeta(raw: string, slug: string, source: string): PersonRecord | null;
|
|
46
|
+
/**
|
|
47
|
+
* List every person recorded for ONE company. Reads
|
|
48
|
+
* `companies/<companySlug>/people/<personSlug>/meta.yaml` for each person
|
|
49
|
+
* folder.
|
|
50
|
+
*
|
|
51
|
+
* - Folders whose name starts with `_` are skipped (e.g. the `_example`
|
|
52
|
+
* template that ships in `companies/_template/people/`).
|
|
53
|
+
* - Folders without a `meta.yaml`, or whose `meta.yaml` has no `name`, are
|
|
54
|
+
* skipped silently — they aren't members yet.
|
|
55
|
+
* - Returns `[]` when the company has no `people/` directory at all.
|
|
56
|
+
*
|
|
57
|
+
* Results are sorted by name (case-insensitive) for stable output.
|
|
58
|
+
*/
|
|
59
|
+
export declare function listCompanyPeople(hqRoot: string, companySlug: string): PersonRecord[];
|
|
60
|
+
/**
|
|
61
|
+
* Case-insensitive keyword search over a person's NAME and EMAIL (plus the
|
|
62
|
+
* folder slug, which is a normalized alias of the name). Pure — operates on an
|
|
63
|
+
* already-loaded list so it's trivially testable and reusable.
|
|
64
|
+
*
|
|
65
|
+
* An empty/whitespace keyword matches nothing (callers should treat that as a
|
|
66
|
+
* usage error rather than "return everyone").
|
|
67
|
+
*/
|
|
68
|
+
export declare function searchPeople(people: PersonRecord[], keyword: string): PersonRecord[];
|
|
69
|
+
/** Outcome of resolving a name to an email address. */
|
|
70
|
+
export type ResolveResult = {
|
|
71
|
+
status: "found";
|
|
72
|
+
email: string;
|
|
73
|
+
person: PersonRecord;
|
|
74
|
+
} | {
|
|
75
|
+
status: "no_email";
|
|
76
|
+
person: PersonRecord;
|
|
77
|
+
} | {
|
|
78
|
+
status: "ambiguous";
|
|
79
|
+
matches: PersonRecord[];
|
|
80
|
+
} | {
|
|
81
|
+
status: "not_found";
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a person NAME to their email, built on top of {@link searchPeople}.
|
|
85
|
+
*
|
|
86
|
+
* Match precedence (narrowest first) so a precise query isn't drowned out by
|
|
87
|
+
* looser substring hits:
|
|
88
|
+
* 1. exact name match (case-insensitive, trimmed)
|
|
89
|
+
* 2. exact folder-slug match
|
|
90
|
+
* 3. substring search over name/email/slug
|
|
91
|
+
*
|
|
92
|
+
* The first tier that yields any match decides the result:
|
|
93
|
+
* - exactly one match with an email → `found`
|
|
94
|
+
* - exactly one match, no email → `no_email`
|
|
95
|
+
* - more than one match → `ambiguous` (caller disambiguates)
|
|
96
|
+
* - no match in any tier → `not_found`
|
|
97
|
+
*/
|
|
98
|
+
export declare function resolveNameToEmail(people: PersonRecord[], name: string): ResolveResult;
|
|
99
|
+
//# sourceMappingURL=people.d.ts.map
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Company people (membership) directory reader + search.
|
|
3
|
+
*
|
|
4
|
+
* The local source of truth for "who belongs to a company" is the per-company
|
|
5
|
+
* people store on disk:
|
|
6
|
+
*
|
|
7
|
+
* companies/<company-slug>/people/<person-slug>/meta.yaml
|
|
8
|
+
*
|
|
9
|
+
* Each `meta.yaml` carries at least `name` and `type` ("internal" | "external")
|
|
10
|
+
* plus optional `email`, `role`, `organization`, `tags`, etc. (see
|
|
11
|
+
* `companies/_template/people/_example/meta.yaml` for the canonical schema).
|
|
12
|
+
*
|
|
13
|
+
* Everything here is scoped to ONE company directory. Callers resolve a single
|
|
14
|
+
* company slug up front; nothing in this module ever enumerates or reads across
|
|
15
|
+
* company boundaries — HQ tenancy rules forbid cross-company member lookups.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="aac5adc3-c336-5d9a-8a1a-edaf16ef4c11")}catch(e){}}();
|
|
19
|
+
import * as fs from "fs";
|
|
20
|
+
import * as path from "path";
|
|
21
|
+
import * as yaml from "js-yaml";
|
|
22
|
+
/**
|
|
23
|
+
* Company slugs map directly onto a filesystem path segment, so we validate
|
|
24
|
+
* them before joining to keep a malicious or fat-fingered `--company` value
|
|
25
|
+
* from escaping the `companies/` tree. Mirrors the slug rules used by
|
|
26
|
+
* cloud-provision.
|
|
27
|
+
*/
|
|
28
|
+
const COMPANY_SLUG_REGEX = /^[A-Za-z0-9._-]+$/;
|
|
29
|
+
const FORBIDDEN_COMPANY_SLUGS = new Set(["personal", ".", ".."]);
|
|
30
|
+
export function assertSafeCompanySlug(slug) {
|
|
31
|
+
if (!slug || !COMPANY_SLUG_REGEX.test(slug)) {
|
|
32
|
+
throw new Error(`Invalid company slug "${slug}" — must match ${COMPANY_SLUG_REGEX.source}`);
|
|
33
|
+
}
|
|
34
|
+
if (FORBIDDEN_COMPANY_SLUGS.has(slug)) {
|
|
35
|
+
throw new Error(`Company slug "${slug}" is reserved and cannot be used here`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Absolute path to a company's `people/` directory. */
|
|
39
|
+
export function companyPeopleDir(hqRoot, companySlug) {
|
|
40
|
+
assertSafeCompanySlug(companySlug);
|
|
41
|
+
return path.join(hqRoot, "companies", companySlug, "people");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Parse a single `meta.yaml` body into a `PersonRecord`. Returns `null` when the
|
|
45
|
+
* file is empty, unparseable, or has no usable `name` — a person record without
|
|
46
|
+
* a name can't be listed, searched, or resolved, so it's skipped rather than
|
|
47
|
+
* surfaced as a half-row. Exported for unit testing.
|
|
48
|
+
*/
|
|
49
|
+
export function parsePersonMeta(raw, slug, source) {
|
|
50
|
+
let doc;
|
|
51
|
+
try {
|
|
52
|
+
doc = yaml.load(raw);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
throw new Error(`Failed to parse ${source}: ${err instanceof Error ? err.message : String(err)}`);
|
|
56
|
+
}
|
|
57
|
+
if (!doc || typeof doc !== "object")
|
|
58
|
+
return null;
|
|
59
|
+
const d = doc;
|
|
60
|
+
const name = typeof d.name === "string" ? d.name.trim() : "";
|
|
61
|
+
if (!name)
|
|
62
|
+
return null;
|
|
63
|
+
const str = (v) => typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
64
|
+
const tags = Array.isArray(d.tags)
|
|
65
|
+
? d.tags.filter((t) => typeof t === "string")
|
|
66
|
+
: undefined;
|
|
67
|
+
return {
|
|
68
|
+
slug,
|
|
69
|
+
name,
|
|
70
|
+
email: str(d.email),
|
|
71
|
+
type: str(d.type),
|
|
72
|
+
role: str(d.role),
|
|
73
|
+
organization: str(d.organization),
|
|
74
|
+
...(tags && tags.length > 0 ? { tags } : {}),
|
|
75
|
+
source,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* List every person recorded for ONE company. Reads
|
|
80
|
+
* `companies/<companySlug>/people/<personSlug>/meta.yaml` for each person
|
|
81
|
+
* folder.
|
|
82
|
+
*
|
|
83
|
+
* - Folders whose name starts with `_` are skipped (e.g. the `_example`
|
|
84
|
+
* template that ships in `companies/_template/people/`).
|
|
85
|
+
* - Folders without a `meta.yaml`, or whose `meta.yaml` has no `name`, are
|
|
86
|
+
* skipped silently — they aren't members yet.
|
|
87
|
+
* - Returns `[]` when the company has no `people/` directory at all.
|
|
88
|
+
*
|
|
89
|
+
* Results are sorted by name (case-insensitive) for stable output.
|
|
90
|
+
*/
|
|
91
|
+
export function listCompanyPeople(hqRoot, companySlug) {
|
|
92
|
+
const dir = companyPeopleDir(hqRoot, companySlug);
|
|
93
|
+
if (!fs.existsSync(dir))
|
|
94
|
+
return [];
|
|
95
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
96
|
+
const people = [];
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (!entry.isDirectory())
|
|
99
|
+
continue;
|
|
100
|
+
if (entry.name.startsWith("_"))
|
|
101
|
+
continue; // _example and other scaffolding
|
|
102
|
+
const metaPath = path.join(dir, entry.name, "meta.yaml");
|
|
103
|
+
if (!fs.existsSync(metaPath))
|
|
104
|
+
continue;
|
|
105
|
+
const raw = fs.readFileSync(metaPath, "utf-8");
|
|
106
|
+
const record = parsePersonMeta(raw, entry.name, metaPath);
|
|
107
|
+
if (record)
|
|
108
|
+
people.push(record);
|
|
109
|
+
}
|
|
110
|
+
people.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
|
|
111
|
+
return people;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Case-insensitive keyword search over a person's NAME and EMAIL (plus the
|
|
115
|
+
* folder slug, which is a normalized alias of the name). Pure — operates on an
|
|
116
|
+
* already-loaded list so it's trivially testable and reusable.
|
|
117
|
+
*
|
|
118
|
+
* An empty/whitespace keyword matches nothing (callers should treat that as a
|
|
119
|
+
* usage error rather than "return everyone").
|
|
120
|
+
*/
|
|
121
|
+
export function searchPeople(people, keyword) {
|
|
122
|
+
const needle = keyword.trim().toLowerCase();
|
|
123
|
+
if (!needle)
|
|
124
|
+
return [];
|
|
125
|
+
return people.filter((p) => {
|
|
126
|
+
const haystacks = [p.name, p.email, p.slug].filter((v) => typeof v === "string");
|
|
127
|
+
return haystacks.some((h) => h.toLowerCase().includes(needle));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a person NAME to their email, built on top of {@link searchPeople}.
|
|
132
|
+
*
|
|
133
|
+
* Match precedence (narrowest first) so a precise query isn't drowned out by
|
|
134
|
+
* looser substring hits:
|
|
135
|
+
* 1. exact name match (case-insensitive, trimmed)
|
|
136
|
+
* 2. exact folder-slug match
|
|
137
|
+
* 3. substring search over name/email/slug
|
|
138
|
+
*
|
|
139
|
+
* The first tier that yields any match decides the result:
|
|
140
|
+
* - exactly one match with an email → `found`
|
|
141
|
+
* - exactly one match, no email → `no_email`
|
|
142
|
+
* - more than one match → `ambiguous` (caller disambiguates)
|
|
143
|
+
* - no match in any tier → `not_found`
|
|
144
|
+
*/
|
|
145
|
+
export function resolveNameToEmail(people, name) {
|
|
146
|
+
const query = name.trim();
|
|
147
|
+
if (!query)
|
|
148
|
+
return { status: "not_found" };
|
|
149
|
+
const lowered = query.toLowerCase();
|
|
150
|
+
const exactName = people.filter((p) => p.name.toLowerCase() === lowered);
|
|
151
|
+
const exactSlug = people.filter((p) => p.slug.toLowerCase() === lowered);
|
|
152
|
+
const substring = searchPeople(people, query);
|
|
153
|
+
const matches = exactName.length > 0
|
|
154
|
+
? exactName
|
|
155
|
+
: exactSlug.length > 0
|
|
156
|
+
? exactSlug
|
|
157
|
+
: substring;
|
|
158
|
+
if (matches.length === 0)
|
|
159
|
+
return { status: "not_found" };
|
|
160
|
+
if (matches.length > 1)
|
|
161
|
+
return { status: "ambiguous", matches };
|
|
162
|
+
const person = matches[0];
|
|
163
|
+
if (!person.email)
|
|
164
|
+
return { status: "no_email", person };
|
|
165
|
+
return { status: "found", email: person.email, person };
|
|
166
|
+
}
|
|
167
|
+
//# sourceMappingURL=people.js.map
|
|
168
|
+
//# debugId=aac5adc3-c336-5d9a-8a1a-edaf16ef4c11
|
package/package.json
CHANGED
|
@@ -156,6 +156,24 @@ describe("submitFeedback", () => {
|
|
|
156
156
|
expect((call.body as Record<string, unknown>)).not.toHaveProperty("company");
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
it("includes screenshot keys when provided, omits the key otherwise", async () => {
|
|
160
|
+
mockVaultApiFetch.mockResolvedValueOnce(jsonResponse(200, { id: "feedback_s1" }));
|
|
161
|
+
await submitFeedback({
|
|
162
|
+
type: "bug",
|
|
163
|
+
title: "With shots",
|
|
164
|
+
body: "Details",
|
|
165
|
+
token: "tok",
|
|
166
|
+
screenshots: ["feedback-screenshots/prs_a/sub/0.png"],
|
|
167
|
+
});
|
|
168
|
+
expect((mockVaultApiFetch.mock.calls[0][0].body as Record<string, unknown>).screenshots).toEqual([
|
|
169
|
+
"feedback-screenshots/prs_a/sub/0.png",
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
mockVaultApiFetch.mockResolvedValueOnce(jsonResponse(200, { id: "feedback_s2" }));
|
|
173
|
+
await submitFeedback({ type: "bug", title: "No shots", body: "Details", token: "tok", screenshots: [] });
|
|
174
|
+
expect(mockVaultApiFetch.mock.calls[1][0].body).not.toHaveProperty("screenshots");
|
|
175
|
+
});
|
|
176
|
+
|
|
159
177
|
it("attaches diagnostics from collectDiagnostics to the request body", async () => {
|
|
160
178
|
mockVaultApiFetch.mockResolvedValueOnce(
|
|
161
179
|
jsonResponse(200, { id: "feedback_diag" }),
|
|
@@ -249,6 +267,32 @@ describe("submitFeedback", () => {
|
|
|
249
267
|
|
|
250
268
|
expect(mockVaultApiFetch).not.toHaveBeenCalled();
|
|
251
269
|
});
|
|
270
|
+
|
|
271
|
+
// HQ-AB: an empty/whitespace title (e.g. `--title ""`, or a title the /hq-bug
|
|
272
|
+
// skill derived to nothing) used to slip past Commander's required-flag check
|
|
273
|
+
// and 400 server-side, flooding Sentry with a context-free warning. The local
|
|
274
|
+
// guard now rejects it before any network call.
|
|
275
|
+
it("throws before fetching when title is empty or whitespace-only", async () => {
|
|
276
|
+
await expect(
|
|
277
|
+
submitFeedback({
|
|
278
|
+
type: "bug",
|
|
279
|
+
title: " \n\t ",
|
|
280
|
+
body: "Real body",
|
|
281
|
+
token: "tok",
|
|
282
|
+
}),
|
|
283
|
+
).rejects.toThrow(/title must not be empty/);
|
|
284
|
+
|
|
285
|
+
await expect(
|
|
286
|
+
submitFeedback({
|
|
287
|
+
type: "feature",
|
|
288
|
+
title: "",
|
|
289
|
+
body: "Real body",
|
|
290
|
+
token: "tok",
|
|
291
|
+
}),
|
|
292
|
+
).rejects.toThrow(/title must not be empty/);
|
|
293
|
+
|
|
294
|
+
expect(mockVaultApiFetch).not.toHaveBeenCalled();
|
|
295
|
+
});
|
|
252
296
|
});
|
|
253
297
|
|
|
254
298
|
// ---------------------------------------------------------------------------
|
package/src/commands/feedback.ts
CHANGED
|
@@ -4,6 +4,7 @@ import chalk from "chalk";
|
|
|
4
4
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
5
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
6
6
|
import { collectDiagnostics } from "../utils/feedback-diagnostics.js";
|
|
7
|
+
import { MAX_SCREENSHOTS, uploadScreenshots } from "../utils/feedback-screenshots.js";
|
|
7
8
|
|
|
8
9
|
export const BODY_MAX_BYTES = 64 * 1024;
|
|
9
10
|
|
|
@@ -17,6 +18,8 @@ export interface FeedbackSubmitOptions {
|
|
|
17
18
|
body: string;
|
|
18
19
|
company?: string;
|
|
19
20
|
token: string;
|
|
21
|
+
/** S3 object keys of already-uploaded screenshots (see uploadScreenshots). */
|
|
22
|
+
screenshots?: string[];
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export async function readBodyFile(
|
|
@@ -46,6 +49,19 @@ export async function readBodyFile(
|
|
|
46
49
|
export async function submitFeedback(
|
|
47
50
|
opts: FeedbackSubmitOptions,
|
|
48
51
|
): Promise<FeedbackResult> {
|
|
52
|
+
// Validate the title locally, symmetric with the body check below. Commander's
|
|
53
|
+
// `requiredOption("--title")` only requires the flag to be PRESENT — an empty
|
|
54
|
+
// or whitespace-only value (`--title ""`, or a title the /hq-bug skill derived
|
|
55
|
+
// to nothing) passes the flag check, then the server rejects it with a 400
|
|
56
|
+
// "title (non-empty string) is required" that floods Sentry as a context-free
|
|
57
|
+
// warning (HQ-AB). Catch it here so the caller gets a clear, actionable error
|
|
58
|
+
// and the bad request never reaches the server.
|
|
59
|
+
if (opts.title.trim().length === 0) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"title must not be empty. Provide a short, non-whitespace title via --title.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
if (opts.body.trim().length === 0) {
|
|
50
66
|
throw new Error(
|
|
51
67
|
"body must not be empty. Provide at least one non-whitespace character.",
|
|
@@ -70,6 +86,9 @@ export async function submitFeedback(
|
|
|
70
86
|
if (opts.company) {
|
|
71
87
|
requestBody.company = opts.company;
|
|
72
88
|
}
|
|
89
|
+
if (opts.screenshots && opts.screenshots.length > 0) {
|
|
90
|
+
requestBody.screenshots = opts.screenshots;
|
|
91
|
+
}
|
|
73
92
|
|
|
74
93
|
const res = await vaultApiFetch({
|
|
75
94
|
token: opts.token,
|
|
@@ -104,19 +123,33 @@ function registerSubcommand(feedbackCmd: Command, type: "bug" | "feature"): void
|
|
|
104
123
|
"Path to a markdown file with the body; use - to read from stdin",
|
|
105
124
|
)
|
|
106
125
|
.option("--company <slug>", "Company slug to associate with the report")
|
|
107
|
-
.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
token
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
126
|
+
.option(
|
|
127
|
+
"--screenshot <path>",
|
|
128
|
+
`Attach a screenshot (repeatable, up to ${MAX_SCREENSHOTS}; .png/.jpg/.jpeg/.webp/.gif)`,
|
|
129
|
+
(value: string, prev: string[]) => [...prev, value],
|
|
130
|
+
[] as string[],
|
|
131
|
+
)
|
|
132
|
+
.action(
|
|
133
|
+
async (opts: { title: string; bodyFile: string; company?: string; screenshot: string[] }) => {
|
|
134
|
+
try {
|
|
135
|
+
const token = await ensureCognitoToken({ interactive: false });
|
|
136
|
+
const body = await readBodyFile(opts.bodyFile);
|
|
137
|
+
// Validate + upload screenshots (direct-to-S3 via presigned PUT)
|
|
138
|
+
// before submitting, so the row references uploaded objects.
|
|
139
|
+
const screenshots = await uploadScreenshots({
|
|
140
|
+
paths: opts.screenshot ?? [],
|
|
141
|
+
token,
|
|
142
|
+
});
|
|
143
|
+
const result = await submitFeedback({
|
|
144
|
+
type,
|
|
145
|
+
title: opts.title,
|
|
146
|
+
body,
|
|
147
|
+
company: opts.company,
|
|
148
|
+
token,
|
|
149
|
+
screenshots,
|
|
150
|
+
});
|
|
151
|
+
console.log(`Submitted: ${result.id}`);
|
|
152
|
+
} catch (err) {
|
|
120
153
|
console.error(
|
|
121
154
|
chalk.red("Error:"),
|
|
122
155
|
err instanceof Error ? err.message : String(err),
|