@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
|
@@ -38,6 +38,7 @@ import { Command } from "commander";
|
|
|
38
38
|
import {
|
|
39
39
|
registerFilesCommand,
|
|
40
40
|
runFilesDelete,
|
|
41
|
+
stripRedundantCompanyScope,
|
|
41
42
|
FilesDeleteHttpError,
|
|
42
43
|
formatFilesDeleteError,
|
|
43
44
|
type FilesDeleteResponse,
|
|
@@ -242,6 +243,137 @@ describe("hq files delete — root/empty prefix rejected client-side", () => {
|
|
|
242
243
|
}
|
|
243
244
|
});
|
|
244
245
|
|
|
246
|
+
// HQ-8F: a caller who pastes an HQ *local* tree path (`companies/<slug>/…`)
|
|
247
|
+
// over-prefixes the bucket-relative vault key; the server rejects it with a 400
|
|
248
|
+
// (INVALID_PREFIX_COMPANIES_SCOPED), the source of the recurring warning. The
|
|
249
|
+
// CLI now strips the redundant scope BEFORE sending, so the local-looking path
|
|
250
|
+
// is gracefully normalized to bucket-relative.
|
|
251
|
+
describe("stripRedundantCompanyScope (HQ-8F, pure)", () => {
|
|
252
|
+
it("strips a leading companies/<slug>/ to the bucket-relative remainder", () => {
|
|
253
|
+
expect(stripRedundantCompanyScope("companies/acme/projects/foo")).toEqual({
|
|
254
|
+
prefix: "projects/foo",
|
|
255
|
+
strippedSlug: "acme",
|
|
256
|
+
});
|
|
257
|
+
expect(stripRedundantCompanyScope("companies/acme/projects/foo/*")).toEqual({
|
|
258
|
+
prefix: "projects/foo/*",
|
|
259
|
+
strippedSlug: "acme",
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("returns an EMPTY remainder for the company-root spellings (→ root-reject)", () => {
|
|
264
|
+
expect(stripRedundantCompanyScope("companies/acme")).toEqual({
|
|
265
|
+
prefix: "",
|
|
266
|
+
strippedSlug: "acme",
|
|
267
|
+
});
|
|
268
|
+
expect(stripRedundantCompanyScope("companies/acme/")).toEqual({
|
|
269
|
+
prefix: "",
|
|
270
|
+
strippedSlug: "acme",
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("leaves an already bucket-relative prefix untouched (null)", () => {
|
|
275
|
+
expect(stripRedundantCompanyScope("projects/foo/*")).toBeNull();
|
|
276
|
+
expect(stripRedundantCompanyScope("reports/q3/")).toBeNull();
|
|
277
|
+
// A non-scope path that merely starts with the word "companies" is NOT a
|
|
278
|
+
// scope prefix and must be left alone.
|
|
279
|
+
expect(stripRedundantCompanyScope("companiesreport/x")).toBeNull();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("hq files delete — HQ-8F company-scope normalization", () => {
|
|
284
|
+
it("strips companies/<slug>/ and sends the BUCKET-RELATIVE prefix (no 400)", async () => {
|
|
285
|
+
fetchSpy.mockResolvedValueOnce(membershipResponse());
|
|
286
|
+
fetchSpy.mockResolvedValueOnce(
|
|
287
|
+
deleteResponse({ dryRun: true, matched: 1, keys: ["projects/foo/a.md"] }),
|
|
288
|
+
);
|
|
289
|
+
fetchSpy.mockResolvedValueOnce(
|
|
290
|
+
deleteResponse({ matched: 1, deleted: 1, tombstoned: 1, keys: ["projects/foo/a.md"] }),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
await runFilesDelete(
|
|
294
|
+
{
|
|
295
|
+
// Trailing slash → also exercises the `/` → `/*` glob normalization.
|
|
296
|
+
prefix: "companies/acme/projects/foo/",
|
|
297
|
+
dryRun: false,
|
|
298
|
+
yes: true,
|
|
299
|
+
companySlug: undefined,
|
|
300
|
+
},
|
|
301
|
+
{ confirm: async () => true },
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const bodies = deleteCallBodies();
|
|
305
|
+
// Both the preview and the delete carry the stripped, bucket-relative prefix.
|
|
306
|
+
expect(bodies.map((b) => b.prefix)).toEqual(["projects/foo/*", "projects/foo/*"]);
|
|
307
|
+
expect(printedErr()).toContain("stripped redundant 'companies/acme/'");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("a bare companies/<slug> strips to empty → root-reject, no network call", async () => {
|
|
311
|
+
const program = buildProgram();
|
|
312
|
+
await expect(
|
|
313
|
+
program.parseAsync(["files", "delete", "companies/acme", "--yes"], {
|
|
314
|
+
from: "user",
|
|
315
|
+
}),
|
|
316
|
+
).rejects.toThrow(/__EXIT__:1/);
|
|
317
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
318
|
+
expect(printedErr()).toContain("Refusing to delete the vault root");
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// HQ-CA: the EXACT-KEY sibling of HQ-8F. When the over-prefixed path has no
|
|
323
|
+
// trailing slash and no wildcard (a literal key like
|
|
324
|
+
// `companies/<slug>/notes/foo.md`), the server takes the exact-key branch where
|
|
325
|
+
// `validateObjectKey` — not `validatePrefix` — rejects it with a 400 ("Invalid
|
|
326
|
+
// key: … do not prefix with 'companies/<slug>/'."). The same client-side strip
|
|
327
|
+
// runs UPSTREAM of the server's exact-vs-glob split, so it normalizes the exact
|
|
328
|
+
// key to bucket-relative and the 400 is never emitted. This locks that path,
|
|
329
|
+
// which #117 only exercised via the trailing-slash (glob) spelling.
|
|
330
|
+
describe("hq files delete — HQ-CA exact-key company-scope normalization", () => {
|
|
331
|
+
it("strips companies/<slug>/ from an EXACT key and sends the bucket-relative key (no glob, no 400)", async () => {
|
|
332
|
+
fetchSpy.mockResolvedValueOnce(membershipResponse());
|
|
333
|
+
fetchSpy.mockResolvedValueOnce(
|
|
334
|
+
deleteResponse({
|
|
335
|
+
dryRun: true,
|
|
336
|
+
mode: "exact",
|
|
337
|
+
prefix: "notes/foo.md",
|
|
338
|
+
matched: 1,
|
|
339
|
+
keys: ["notes/foo.md"],
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
342
|
+
fetchSpy.mockResolvedValueOnce(
|
|
343
|
+
deleteResponse({
|
|
344
|
+
mode: "exact",
|
|
345
|
+
prefix: "notes/foo.md",
|
|
346
|
+
matched: 1,
|
|
347
|
+
deleted: 1,
|
|
348
|
+
tombstoned: 1,
|
|
349
|
+
keys: ["notes/foo.md"],
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
await runFilesDelete(
|
|
354
|
+
{
|
|
355
|
+
// No trailing slash, no wildcard → server takes the EXACT-key branch.
|
|
356
|
+
prefix: "companies/acme/notes/foo.md",
|
|
357
|
+
dryRun: false,
|
|
358
|
+
yes: true,
|
|
359
|
+
companySlug: undefined,
|
|
360
|
+
},
|
|
361
|
+
{ confirm: async () => true },
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const bodies = deleteCallBodies();
|
|
365
|
+
// Preview + delete both carry the stripped, bucket-relative EXACT key —
|
|
366
|
+
// never the `companies/acme/...` over-prefix that 400s, and never glob-ified
|
|
367
|
+
// into a prefix delete.
|
|
368
|
+
expect(bodies.map((b) => b.prefix)).toEqual(["notes/foo.md", "notes/foo.md"]);
|
|
369
|
+
for (const b of bodies) {
|
|
370
|
+
expect(b.prefix.startsWith("companies/")).toBe(false);
|
|
371
|
+
expect(b.prefix).not.toContain("*");
|
|
372
|
+
}
|
|
373
|
+
expect(printedErr()).toContain("stripped redundant 'companies/acme/'");
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
245
377
|
describe("hq files delete — server error mapping", () => {
|
|
246
378
|
it("403 surfaces a clear not-authorized message and exits 1", async () => {
|
|
247
379
|
fetchSpy.mockResolvedValueOnce(membershipResponse());
|
package/src/commands/files.ts
CHANGED
|
@@ -782,17 +782,58 @@ function printKeyPreview(resp: FilesDeleteResponse): void {
|
|
|
782
782
|
}
|
|
783
783
|
}
|
|
784
784
|
|
|
785
|
+
/**
|
|
786
|
+
* The vault bucket is already company-scoped, so a delete prefix must be
|
|
787
|
+
* BUCKET-RELATIVE (e.g. `projects/foo/*`). A caller who pastes an HQ *local*
|
|
788
|
+
* tree path (`companies/<slug>/projects/foo`) over-prefixes it; the server then
|
|
789
|
+
* rejects it with INVALID_PREFIX_COMPANIES_SCOPED (HTTP 400), the source of the
|
|
790
|
+
* recurring Sentry warning HQ-8F. Strip a redundant leading `companies/<slug>/`
|
|
791
|
+
* so the local-looking path is normalized to the bucket-relative key the vault
|
|
792
|
+
* actually stores. Returns the stripped slug for a one-line notice, or null when
|
|
793
|
+
* there was nothing to strip. Pure → unit-testable.
|
|
794
|
+
*
|
|
795
|
+
* This runs UPSTREAM of the server's exact-vs-glob branch, so it covers both
|
|
796
|
+
* the glob spelling (`companies/<slug>/projects/foo/*` → `validatePrefix`,
|
|
797
|
+
* HQ-8F) and the EXACT-key spelling (`companies/<slug>/notes/foo.md` →
|
|
798
|
+
* `validateObjectKey`, HQ-CA) with the same normalization.
|
|
799
|
+
*/
|
|
800
|
+
export function stripRedundantCompanyScope(
|
|
801
|
+
prefix: string,
|
|
802
|
+
): { prefix: string; strippedSlug: string } | null {
|
|
803
|
+
const m = /^companies\/([^/]+)(?:\/(.*))?$/.exec(prefix);
|
|
804
|
+
if (!m) return null;
|
|
805
|
+
return { prefix: m[2] ?? "", strippedSlug: m[1] };
|
|
806
|
+
}
|
|
807
|
+
|
|
785
808
|
export async function runFilesDelete(
|
|
786
809
|
params: RunFilesDeleteParams,
|
|
787
810
|
deps: { confirm?: ConfirmFn } = {},
|
|
788
811
|
): Promise<void> {
|
|
789
812
|
const confirm = deps.confirm ?? realConfirm;
|
|
790
813
|
|
|
814
|
+
// The vault is already company-scoped — a `companies/<slug>/` prefix is the HQ
|
|
815
|
+
// LOCAL tree layout, not a vault key, and the server 400s it (HQ-8F). Strip it
|
|
816
|
+
// here so a pasted local path is gracefully normalized to bucket-relative
|
|
817
|
+
// BEFORE the dry-run/preview (so the operator still sees the exact keys and
|
|
818
|
+
// confirms the right target). If stripping empties the prefix, the root-reject
|
|
819
|
+
// below catches it with a clear message.
|
|
820
|
+
const scope = stripRedundantCompanyScope(params.prefix);
|
|
821
|
+
if (scope) {
|
|
822
|
+
console.error(
|
|
823
|
+
chalk.yellow(
|
|
824
|
+
`Note: stripped redundant 'companies/${scope.strippedSlug}/' — the vault ` +
|
|
825
|
+
`is already company-scoped; using bucket-relative ` +
|
|
826
|
+
`'${scope.prefix || "(root)"}'.`,
|
|
827
|
+
),
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
const rawPrefix = scope ? scope.prefix : params.prefix;
|
|
831
|
+
|
|
791
832
|
// Normalize exactly as the share/unshare/acl paths do (trailing `/` → `/*`),
|
|
792
833
|
// then reject the root/empty prefix CLIENT-side so a typo never reaches the
|
|
793
834
|
// server as a vault-wide delete. The server enforces this too (defense in
|
|
794
835
|
// depth), but failing fast here is clearer and avoids a wasted round-trip.
|
|
795
|
-
const normalized = normalizeFilePrefix(
|
|
836
|
+
const normalized = normalizeFilePrefix(rawPrefix);
|
|
796
837
|
if (normalized === "" || normalized === "*" || normalized === "/*") {
|
|
797
838
|
console.error(
|
|
798
839
|
chalk.red(
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the `hq people` capability:
|
|
3
|
+
* - utils/people.ts — parse, list, search, resolve (pure + filesystem)
|
|
4
|
+
* - people.ts — active-company resolution + wired list/search/resolve
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as os from "os";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
assertSafeCompanySlug,
|
|
15
|
+
companyPeopleDir,
|
|
16
|
+
parsePersonMeta,
|
|
17
|
+
listCompanyPeople,
|
|
18
|
+
searchPeople,
|
|
19
|
+
resolveNameToEmail,
|
|
20
|
+
type PersonRecord,
|
|
21
|
+
} from "../utils/people.js";
|
|
22
|
+
import { registerPeopleCommand, resolveCompanySlug } from "./people.js";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Filesystem fixture helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
let tmpRoot: string;
|
|
29
|
+
|
|
30
|
+
function makePerson(
|
|
31
|
+
company: string,
|
|
32
|
+
slug: string,
|
|
33
|
+
meta: Record<string, unknown> | string,
|
|
34
|
+
): void {
|
|
35
|
+
const dir = path.join(tmpRoot, "companies", company, "people", slug);
|
|
36
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
37
|
+
const body =
|
|
38
|
+
typeof meta === "string"
|
|
39
|
+
? meta
|
|
40
|
+
: Object.entries(meta)
|
|
41
|
+
.map(([k, v]) =>
|
|
42
|
+
Array.isArray(v)
|
|
43
|
+
? `${k}: [${v.join(", ")}]`
|
|
44
|
+
: `${k}: ${JSON.stringify(v)}`,
|
|
45
|
+
)
|
|
46
|
+
.join("\n");
|
|
47
|
+
fs.writeFileSync(path.join(dir, "meta.yaml"), body + "\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writeCompaniesManifest(
|
|
51
|
+
companies: Record<string, Record<string, unknown> | null>,
|
|
52
|
+
): void {
|
|
53
|
+
const dir = path.join(tmpRoot, "companies");
|
|
54
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
55
|
+
const yamlBody =
|
|
56
|
+
"companies:\n" +
|
|
57
|
+
Object.entries(companies)
|
|
58
|
+
.map(([slug, entry]) => {
|
|
59
|
+
if (!entry) return ` ${slug}:`;
|
|
60
|
+
const fields = Object.entries(entry)
|
|
61
|
+
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
|
|
62
|
+
.join("\n");
|
|
63
|
+
return ` ${slug}:\n${fields}`;
|
|
64
|
+
})
|
|
65
|
+
.join("\n") +
|
|
66
|
+
"\n";
|
|
67
|
+
fs.writeFileSync(path.join(dir, "manifest.yaml"), yamlBody);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-people-"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
76
|
+
vi.restoreAllMocks();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// assertSafeCompanySlug
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
describe("assertSafeCompanySlug", () => {
|
|
84
|
+
it("accepts ordinary slugs", () => {
|
|
85
|
+
expect(() => assertSafeCompanySlug("indigo")).not.toThrow();
|
|
86
|
+
expect(() => assertSafeCompanySlug("acme-co_1.2")).not.toThrow();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rejects path-traversal and separators", () => {
|
|
90
|
+
expect(() => assertSafeCompanySlug("..")).toThrow();
|
|
91
|
+
expect(() => assertSafeCompanySlug("a/b")).toThrow();
|
|
92
|
+
expect(() => assertSafeCompanySlug("../etc")).toThrow();
|
|
93
|
+
expect(() => assertSafeCompanySlug("")).toThrow();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("rejects reserved slugs", () => {
|
|
97
|
+
expect(() => assertSafeCompanySlug("personal")).toThrow(/reserved/);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// parsePersonMeta
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
describe("parsePersonMeta", () => {
|
|
106
|
+
it("parses a full record and trims fields", () => {
|
|
107
|
+
const rec = parsePersonMeta(
|
|
108
|
+
"name: Jane Smith\nemail: jane@example.com\ntype: internal\nrole: Head of Eng\ntags: [eng, lead]\n",
|
|
109
|
+
"jane-smith",
|
|
110
|
+
"/x/meta.yaml",
|
|
111
|
+
);
|
|
112
|
+
expect(rec).toEqual({
|
|
113
|
+
slug: "jane-smith",
|
|
114
|
+
name: "Jane Smith",
|
|
115
|
+
email: "jane@example.com",
|
|
116
|
+
type: "internal",
|
|
117
|
+
role: "Head of Eng",
|
|
118
|
+
tags: ["eng", "lead"],
|
|
119
|
+
source: "/x/meta.yaml",
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns null when name is missing or blank", () => {
|
|
124
|
+
expect(parsePersonMeta("email: a@b.com\n", "x", "/x")).toBeNull();
|
|
125
|
+
expect(parsePersonMeta("name: ' '\n", "x", "/x")).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns null for empty / non-object docs", () => {
|
|
129
|
+
expect(parsePersonMeta("", "x", "/x")).toBeNull();
|
|
130
|
+
expect(parsePersonMeta("- a\n- b\n", "x", "/x")).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("throws on unparseable yaml", () => {
|
|
134
|
+
expect(() => parsePersonMeta("name: [unterminated\n", "x", "/x")).toThrow(
|
|
135
|
+
/Failed to parse/,
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// listCompanyPeople
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
describe("listCompanyPeople", () => {
|
|
145
|
+
it("returns [] when the company has no people directory", () => {
|
|
146
|
+
writeCompaniesManifest({ acme: { status: "active" } });
|
|
147
|
+
expect(listCompanyPeople(tmpRoot, "acme")).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("reads people, skips _scaffolding, nameless, and meta-less folders, sorted by name", () => {
|
|
151
|
+
makePerson("acme", "zed", { name: "Zed Zebra", email: "zed@acme.com" });
|
|
152
|
+
makePerson("acme", "ann", { name: "Ann Apple", email: "ann@acme.com" });
|
|
153
|
+
makePerson("acme", "_example", { name: "Template Person" });
|
|
154
|
+
makePerson("acme", "nameless", { email: "noone@acme.com" });
|
|
155
|
+
// folder with no meta.yaml
|
|
156
|
+
fs.mkdirSync(path.join(tmpRoot, "companies", "acme", "people", "empty"), {
|
|
157
|
+
recursive: true,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const people = listCompanyPeople(tmpRoot, "acme");
|
|
161
|
+
expect(people.map((p) => p.name)).toEqual(["Ann Apple", "Zed Zebra"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("never reads outside the named company (tenancy)", () => {
|
|
165
|
+
makePerson("acme", "ann", { name: "Ann Apple", email: "ann@acme.com" });
|
|
166
|
+
makePerson("other", "bob", { name: "Bob Other", email: "bob@other.com" });
|
|
167
|
+
const people = listCompanyPeople(tmpRoot, "acme");
|
|
168
|
+
expect(people.map((p) => p.name)).toEqual(["Ann Apple"]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("rejects an unsafe company slug", () => {
|
|
172
|
+
expect(() => listCompanyPeople(tmpRoot, "../other")).toThrow();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// searchPeople
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
const SAMPLE: PersonRecord[] = [
|
|
181
|
+
{ slug: "jane-smith", name: "Jane Smith", email: "jane@example.com", source: "a" },
|
|
182
|
+
{ slug: "john-doe", name: "John Doe", email: "john@acme.com", source: "b" },
|
|
183
|
+
{ slug: "janet-lee", name: "Janet Lee", source: "c" },
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
describe("searchPeople", () => {
|
|
187
|
+
it("matches on name (case-insensitive)", () => {
|
|
188
|
+
// "jane" is a substring of both "Jane Smith" and "Janet Lee".
|
|
189
|
+
expect(searchPeople(SAMPLE, "jane").map((p) => p.slug)).toEqual([
|
|
190
|
+
"jane-smith",
|
|
191
|
+
"janet-lee",
|
|
192
|
+
]);
|
|
193
|
+
expect(searchPeople(SAMPLE, "smith").map((p) => p.slug)).toEqual([
|
|
194
|
+
"jane-smith",
|
|
195
|
+
]);
|
|
196
|
+
expect(searchPeople(SAMPLE, "JAN").map((p) => p.slug)).toEqual([
|
|
197
|
+
"jane-smith",
|
|
198
|
+
"janet-lee",
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("matches on email", () => {
|
|
203
|
+
expect(searchPeople(SAMPLE, "acme.com").map((p) => p.slug)).toEqual([
|
|
204
|
+
"john-doe",
|
|
205
|
+
]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("matches on slug", () => {
|
|
209
|
+
expect(searchPeople(SAMPLE, "janet-lee").map((p) => p.slug)).toEqual([
|
|
210
|
+
"janet-lee",
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("returns nothing for an empty/whitespace keyword", () => {
|
|
215
|
+
expect(searchPeople(SAMPLE, "")).toEqual([]);
|
|
216
|
+
expect(searchPeople(SAMPLE, " ")).toEqual([]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("returns nothing when no one matches", () => {
|
|
220
|
+
expect(searchPeople(SAMPLE, "zzz")).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// resolveNameToEmail
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
describe("resolveNameToEmail", () => {
|
|
229
|
+
it("resolves an exact name to its email", () => {
|
|
230
|
+
expect(resolveNameToEmail(SAMPLE, "Jane Smith")).toEqual({
|
|
231
|
+
status: "found",
|
|
232
|
+
email: "jane@example.com",
|
|
233
|
+
person: SAMPLE[0],
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("is case-insensitive on exact name", () => {
|
|
238
|
+
const r = resolveNameToEmail(SAMPLE, "jane smith");
|
|
239
|
+
expect(r.status).toBe("found");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("prefers an exact name over looser substring matches", () => {
|
|
243
|
+
const people: PersonRecord[] = [
|
|
244
|
+
{ slug: "jan", name: "Jan", email: "jan@x.com", source: "a" },
|
|
245
|
+
{ slug: "jana", name: "Janabc", email: "jana@x.com", source: "b" },
|
|
246
|
+
];
|
|
247
|
+
expect(resolveNameToEmail(people, "Jan")).toMatchObject({
|
|
248
|
+
status: "found",
|
|
249
|
+
email: "jan@x.com",
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("resolves via slug when name doesn't match exactly", () => {
|
|
254
|
+
expect(resolveNameToEmail(SAMPLE, "john-doe")).toMatchObject({
|
|
255
|
+
status: "found",
|
|
256
|
+
email: "john@acme.com",
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("reports ambiguity when a substring matches several", () => {
|
|
261
|
+
const r = resolveNameToEmail(SAMPLE, "jan");
|
|
262
|
+
expect(r.status).toBe("ambiguous");
|
|
263
|
+
if (r.status === "ambiguous") {
|
|
264
|
+
expect(r.matches.map((m) => m.slug)).toEqual(["jane-smith", "janet-lee"]);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("reports no_email when the sole match has no email", () => {
|
|
269
|
+
expect(resolveNameToEmail(SAMPLE, "Janet Lee")).toMatchObject({
|
|
270
|
+
status: "no_email",
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("reports not_found when nothing matches", () => {
|
|
275
|
+
expect(resolveNameToEmail(SAMPLE, "Nobody")).toEqual({
|
|
276
|
+
status: "not_found",
|
|
277
|
+
});
|
|
278
|
+
expect(resolveNameToEmail(SAMPLE, " ")).toEqual({ status: "not_found" });
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// resolveCompanySlug
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
describe("resolveCompanySlug", () => {
|
|
287
|
+
it("passes through an explicit, valid slug", () => {
|
|
288
|
+
expect(resolveCompanySlug(tmpRoot, "indigo")).toBe("indigo");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("rejects an explicit unsafe slug", () => {
|
|
292
|
+
expect(() => resolveCompanySlug(tmpRoot, "../escape")).toThrow();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("defaults to the sole company in the manifest", () => {
|
|
296
|
+
writeCompaniesManifest({ indigo: { status: "active" } });
|
|
297
|
+
expect(resolveCompanySlug(tmpRoot, undefined)).toBe("indigo");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("ignores archived companies when defaulting", () => {
|
|
301
|
+
writeCompaniesManifest({
|
|
302
|
+
indigo: { status: "active" },
|
|
303
|
+
old: { status: "archived" },
|
|
304
|
+
});
|
|
305
|
+
expect(resolveCompanySlug(tmpRoot, undefined)).toBe("indigo");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("requires --company when several companies exist", () => {
|
|
309
|
+
writeCompaniesManifest({ a: { status: "active" }, b: { status: "active" } });
|
|
310
|
+
expect(() => resolveCompanySlug(tmpRoot, undefined)).toThrow(
|
|
311
|
+
/Multiple companies/,
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("errors clearly when the manifest is absent", () => {
|
|
316
|
+
expect(() => resolveCompanySlug(tmpRoot, undefined)).toThrow(
|
|
317
|
+
/manifest\.yaml not found/,
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// companyPeopleDir
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
describe("companyPeopleDir", () => {
|
|
327
|
+
it("builds the scoped path", () => {
|
|
328
|
+
expect(companyPeopleDir("/hq", "indigo")).toBe(
|
|
329
|
+
path.join("/hq", "companies", "indigo", "people"),
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Wired CLI surface (list / search / resolve)
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
function buildProgram(): Command {
|
|
339
|
+
const program = new Command();
|
|
340
|
+
program.exitOverride(); // throw instead of process.exit on commander errors
|
|
341
|
+
registerPeopleCommand(program);
|
|
342
|
+
return program;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function run(args: string[]): Promise<void> {
|
|
346
|
+
await buildProgram().parseAsync(["node", "hq", ...args]);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
describe("hq people (wired)", () => {
|
|
350
|
+
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
351
|
+
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
352
|
+
let exitSpy: ReturnType<typeof vi.spyOn>;
|
|
353
|
+
|
|
354
|
+
beforeEach(() => {
|
|
355
|
+
writeCompaniesManifest({ acme: { status: "active" } });
|
|
356
|
+
makePerson("acme", "jane-smith", {
|
|
357
|
+
name: "Jane Smith",
|
|
358
|
+
email: "jane@acme.com",
|
|
359
|
+
role: "CEO",
|
|
360
|
+
type: "internal",
|
|
361
|
+
});
|
|
362
|
+
makePerson("acme", "john-doe", {
|
|
363
|
+
name: "John Doe",
|
|
364
|
+
email: "john@acme.com",
|
|
365
|
+
type: "internal",
|
|
366
|
+
});
|
|
367
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
368
|
+
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
369
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(((): never => {
|
|
370
|
+
throw new Error("__exit__");
|
|
371
|
+
}) as never);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("list --json emits both people", async () => {
|
|
375
|
+
await run(["people", "list", "--company", "acme", "--hq-root", tmpRoot, "--json"]);
|
|
376
|
+
const out = JSON.parse(logSpy.mock.calls[0][0] as string);
|
|
377
|
+
expect(out.map((p: PersonRecord) => p.name).sort()).toEqual([
|
|
378
|
+
"Jane Smith",
|
|
379
|
+
"John Doe",
|
|
380
|
+
]);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("search narrows to the matching person", async () => {
|
|
384
|
+
await run([
|
|
385
|
+
"people",
|
|
386
|
+
"search",
|
|
387
|
+
"jane",
|
|
388
|
+
"--company",
|
|
389
|
+
"acme",
|
|
390
|
+
"--hq-root",
|
|
391
|
+
tmpRoot,
|
|
392
|
+
"--json",
|
|
393
|
+
]);
|
|
394
|
+
const out = JSON.parse(logSpy.mock.calls[0][0] as string);
|
|
395
|
+
expect(out).toHaveLength(1);
|
|
396
|
+
expect(out[0].email).toBe("jane@acme.com");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("resolve prints the bare email on stdout", async () => {
|
|
400
|
+
await run([
|
|
401
|
+
"people",
|
|
402
|
+
"resolve",
|
|
403
|
+
"Jane Smith",
|
|
404
|
+
"--company",
|
|
405
|
+
"acme",
|
|
406
|
+
"--hq-root",
|
|
407
|
+
tmpRoot,
|
|
408
|
+
]);
|
|
409
|
+
expect(logSpy).toHaveBeenCalledWith("jane@acme.com");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("resolve exits non-zero when not found", async () => {
|
|
413
|
+
await expect(
|
|
414
|
+
run([
|
|
415
|
+
"people",
|
|
416
|
+
"resolve",
|
|
417
|
+
"Nobody",
|
|
418
|
+
"--company",
|
|
419
|
+
"acme",
|
|
420
|
+
"--hq-root",
|
|
421
|
+
tmpRoot,
|
|
422
|
+
]),
|
|
423
|
+
).rejects.toThrow("__exit__");
|
|
424
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
425
|
+
});
|
|
426
|
+
});
|