@indigoai-us/hq-cli 5.47.15 → 5.47.16
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/dist/commands/people.d.ts +22 -0
- package/dist/commands/people.js +187 -0
- package/dist/index.js +6 -2
- package/dist/utils/people.d.ts +99 -0
- package/dist/utils/people.js +168 -0
- package/package.json +1 -1
- package/src/commands/people.test.ts +426 -0
- package/src/commands/people.ts +240 -0
- package/src/index.ts +4 -0
- package/src/utils/people.ts +222 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq people` — read a company's people (membership) records from the local HQ
|
|
3
|
+
* tree and search/resolve over them.
|
|
4
|
+
*
|
|
5
|
+
* hq people list [--company <slug>] [--json]
|
|
6
|
+
* hq people search <keyword> [--company <slug>] [--json]
|
|
7
|
+
* hq people resolve <name> [--company <slug>] [--json]
|
|
8
|
+
*
|
|
9
|
+
* Source of truth is `companies/<company>/people/<person>/meta.yaml`. Every
|
|
10
|
+
* subcommand operates on exactly ONE company (the active company, or the one
|
|
11
|
+
* named by `--company`); nothing reads across company boundaries.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "fs";
|
|
15
|
+
import { Command, Option } from "commander";
|
|
16
|
+
import chalk from "chalk";
|
|
17
|
+
import * as yaml from "js-yaml";
|
|
18
|
+
import { findHqRoot } from "../utils/manifest.js";
|
|
19
|
+
import { manifestPath, type ManifestDoc } from "./cloud-provision.js";
|
|
20
|
+
import {
|
|
21
|
+
assertSafeCompanySlug,
|
|
22
|
+
listCompanyPeople,
|
|
23
|
+
searchPeople,
|
|
24
|
+
resolveNameToEmail,
|
|
25
|
+
companyPeopleDir,
|
|
26
|
+
type PersonRecord,
|
|
27
|
+
} from "../utils/people.js";
|
|
28
|
+
|
|
29
|
+
interface PeopleScopeOpts {
|
|
30
|
+
company?: string;
|
|
31
|
+
hqRoot?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Companies that still exist (anything not explicitly `status: archived`). */
|
|
35
|
+
function activeCompanySlugs(manifest: ManifestDoc): string[] {
|
|
36
|
+
const companies = manifest.companies ?? {};
|
|
37
|
+
return Object.entries(companies)
|
|
38
|
+
.filter(([, entry]) => (entry?.status ?? "active") !== "archived")
|
|
39
|
+
.map(([slug]) => slug)
|
|
40
|
+
.sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the single company to operate on. Explicit `--company` always wins
|
|
45
|
+
* (after a path-safety check). Otherwise the active company is inferred from
|
|
46
|
+
* `companies/manifest.yaml`: if exactly one company exists it's used; if several
|
|
47
|
+
* do, the caller must disambiguate with `--company`.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveCompanySlug(
|
|
50
|
+
hqRoot: string,
|
|
51
|
+
explicit: string | undefined,
|
|
52
|
+
): string {
|
|
53
|
+
if (explicit) {
|
|
54
|
+
assertSafeCompanySlug(explicit);
|
|
55
|
+
return explicit;
|
|
56
|
+
}
|
|
57
|
+
const mPath = manifestPath(hqRoot);
|
|
58
|
+
if (!fs.existsSync(mPath)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"Could not determine the active company — companies/manifest.yaml not found. " +
|
|
61
|
+
"Re-run with --company <slug>.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
let manifest: ManifestDoc;
|
|
65
|
+
try {
|
|
66
|
+
manifest = yaml.load(fs.readFileSync(mPath, "utf-8")) as ManifestDoc;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`companies/manifest.yaml is malformed: ${err instanceof Error ? err.message : String(err)}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const slugs = activeCompanySlugs(manifest ?? {});
|
|
73
|
+
if (slugs.length === 1) return slugs[0];
|
|
74
|
+
if (slugs.length === 0) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
"No companies found in companies/manifest.yaml — re-run with --company <slug>.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
throw new Error(
|
|
80
|
+
"Multiple companies found — re-run with --company <slug> to pick one:\n" +
|
|
81
|
+
slugs.map((s) => ` --company ${s}`).join("\n"),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Resolve the HQ root: explicit override (tests) → cwd walk-up. */
|
|
86
|
+
function resolveHqRoot(opts: PeopleScopeOpts): string {
|
|
87
|
+
return opts.hqRoot ?? findHqRoot();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function printPeopleTable(people: PersonRecord[]): void {
|
|
91
|
+
const nameW = Math.max(4, ...people.map((p) => p.name.length));
|
|
92
|
+
const emailW = Math.max(5, ...people.map((p) => (p.email ?? "—").length));
|
|
93
|
+
const roleW = Math.max(4, ...people.map((p) => (p.role ?? "—").length));
|
|
94
|
+
console.log(
|
|
95
|
+
chalk.bold(
|
|
96
|
+
[
|
|
97
|
+
"NAME".padEnd(nameW),
|
|
98
|
+
"EMAIL".padEnd(emailW),
|
|
99
|
+
"ROLE".padEnd(roleW),
|
|
100
|
+
"TYPE",
|
|
101
|
+
].join(" "),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
for (const p of people) {
|
|
105
|
+
console.log(
|
|
106
|
+
[
|
|
107
|
+
p.name.padEnd(nameW),
|
|
108
|
+
(p.email ?? "—").padEnd(emailW),
|
|
109
|
+
(p.role ?? "—").padEnd(roleW),
|
|
110
|
+
p.type ?? "—",
|
|
111
|
+
].join(" "),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function fail(message: string): never {
|
|
117
|
+
console.error(chalk.red(message));
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function registerPeopleCommand(program: Command): void {
|
|
122
|
+
const people = program
|
|
123
|
+
.command("people")
|
|
124
|
+
.description(
|
|
125
|
+
"List, search, and resolve a company's people (from companies/<co>/people)",
|
|
126
|
+
)
|
|
127
|
+
.option(
|
|
128
|
+
"--company <slug>",
|
|
129
|
+
"Company slug to scope to (defaults to the active company)",
|
|
130
|
+
)
|
|
131
|
+
// Hidden escape hatch for tests / non-standard layouts — point the reader at
|
|
132
|
+
// an explicit HQ tree root instead of walking up from cwd.
|
|
133
|
+
.addOption(
|
|
134
|
+
new Option(
|
|
135
|
+
"--hq-root <path>",
|
|
136
|
+
"Override the HQ tree root (advanced)",
|
|
137
|
+
).hideHelp(),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
people
|
|
141
|
+
.command("list")
|
|
142
|
+
.description("List all people recorded for the company")
|
|
143
|
+
.option("--json", "Output JSON instead of a table")
|
|
144
|
+
.action((opts: { json?: boolean }) => {
|
|
145
|
+
try {
|
|
146
|
+
const scope = people.opts() as PeopleScopeOpts;
|
|
147
|
+
const hqRoot = resolveHqRoot(scope);
|
|
148
|
+
const slug = resolveCompanySlug(hqRoot, scope.company);
|
|
149
|
+
const records = listCompanyPeople(hqRoot, slug);
|
|
150
|
+
|
|
151
|
+
if (opts.json) {
|
|
152
|
+
console.log(JSON.stringify(records, null, 2));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (records.length === 0) {
|
|
156
|
+
console.log(
|
|
157
|
+
chalk.gray(
|
|
158
|
+
`No people recorded for '${slug}' (looked in ${companyPeopleDir(hqRoot, slug)}).`,
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
printPeopleTable(records);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
people
|
|
170
|
+
.command("search <keyword>")
|
|
171
|
+
.description("Keyword search over people names and emails")
|
|
172
|
+
.option("--json", "Output JSON instead of a table")
|
|
173
|
+
.action((keyword: string, opts: { json?: boolean }) => {
|
|
174
|
+
try {
|
|
175
|
+
const scope = people.opts() as PeopleScopeOpts;
|
|
176
|
+
const hqRoot = resolveHqRoot(scope);
|
|
177
|
+
const slug = resolveCompanySlug(hqRoot, scope.company);
|
|
178
|
+
const matches = searchPeople(listCompanyPeople(hqRoot, slug), keyword);
|
|
179
|
+
|
|
180
|
+
if (opts.json) {
|
|
181
|
+
console.log(JSON.stringify(matches, null, 2));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (matches.length === 0) {
|
|
185
|
+
console.log(chalk.gray(`No people in '${slug}' match "${keyword}".`));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
printPeopleTable(matches);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
people
|
|
195
|
+
.command("resolve <name>")
|
|
196
|
+
.description("Resolve a person name to their email address")
|
|
197
|
+
.option("--json", "Output JSON instead of plain text")
|
|
198
|
+
.action((name: string, opts: { json?: boolean }) => {
|
|
199
|
+
try {
|
|
200
|
+
const scope = people.opts() as PeopleScopeOpts;
|
|
201
|
+
const hqRoot = resolveHqRoot(scope);
|
|
202
|
+
const slug = resolveCompanySlug(hqRoot, scope.company);
|
|
203
|
+
const result = resolveNameToEmail(listCompanyPeople(hqRoot, slug), name);
|
|
204
|
+
|
|
205
|
+
if (opts.json) {
|
|
206
|
+
console.log(JSON.stringify(result, null, 2));
|
|
207
|
+
if (result.status === "found") return;
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
switch (result.status) {
|
|
212
|
+
case "found":
|
|
213
|
+
// Bare email on stdout so callers can capture it directly.
|
|
214
|
+
console.log(result.email);
|
|
215
|
+
return;
|
|
216
|
+
case "no_email":
|
|
217
|
+
fail(
|
|
218
|
+
`Found '${result.person.name}' in '${slug}' but no email is recorded for them.`,
|
|
219
|
+
);
|
|
220
|
+
break;
|
|
221
|
+
case "ambiguous":
|
|
222
|
+
console.error(
|
|
223
|
+
chalk.yellow(
|
|
224
|
+
`"${name}" matches ${result.matches.length} people in '${slug}' — be more specific:`,
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
for (const m of result.matches) {
|
|
228
|
+
console.error(` ${m.name}${m.email ? ` <${m.email}>` : ""}`);
|
|
229
|
+
}
|
|
230
|
+
process.exit(1);
|
|
231
|
+
break;
|
|
232
|
+
case "not_found":
|
|
233
|
+
fail(`No person matching "${name}" found in '${slug}'.`);
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
} catch (err) {
|
|
237
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { registerGroupGrantsCommand } from "./commands/group-grants.js";
|
|
|
36
36
|
import { registerFilesCommand } from "./commands/files.js";
|
|
37
37
|
import { registerFilesBrowseCommands } from "./commands/files-browse.js";
|
|
38
38
|
import { registerMembersCommand } from "./commands/members.js";
|
|
39
|
+
import { registerPeopleCommand } from "./commands/people.js";
|
|
39
40
|
import { registerDmCommand } from "./commands/dm.js";
|
|
40
41
|
import { registerFeedbackCommand } from "./commands/feedback.js";
|
|
41
42
|
import { registerMeetingsCommand } from "./commands/meetings.js";
|
|
@@ -166,6 +167,9 @@ registerFilesBrowseCommands(filesCmd);
|
|
|
166
167
|
|
|
167
168
|
// Membership management (subcommand group — hq members invite|list|revoke)
|
|
168
169
|
registerMembersCommand(program);
|
|
170
|
+
// People directory (subcommand group — hq people list|search|resolve), reading
|
|
171
|
+
// the local companies/<co>/people store scoped to one company.
|
|
172
|
+
registerPeopleCommand(program);
|
|
169
173
|
registerDmCommand(program);
|
|
170
174
|
|
|
171
175
|
// Onboarding (top-level — Cognito + vault-service provisioning)
|