@fluid-app/fluid-cli-genealogy 0.0.0-stage → 0.1.1

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/index.mjs ADDED
@@ -0,0 +1,1242 @@
1
+ import prompts from "prompts";
2
+ import { createDomainCommand } from "@fluid-app/fluid-cli";
3
+ //#region src/api.ts
4
+ const BASE = "/api/company/v2026-10/genealogy";
5
+ /** The tree `fluid genealogy` reads when --tree is not given, matching the
6
+ * Rails genealogy CLI. */
7
+ const DEFAULT_TREE = "placement";
8
+ /** Node pages hold at most 1,000 rows; history-style pages (placements,
9
+ * holding tank, move requests and jobs) at most 100. */
10
+ const NODE_PAGE_MAX = 1e3;
11
+ const HISTORY_PAGE_MAX = 100;
12
+ /** An unbounded read stops here instead of returning a partial list. */
13
+ const MAX_PAGES = 200;
14
+ /** Fan-out reads (each child when ranking legs, each relation kind) run
15
+ * this many at a time. */
16
+ const CONCURRENCY = 5;
17
+ const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
18
+ var GenealogyError = class extends Error {
19
+ /** The API's failure type, when the error restates an API failure. */
20
+ type;
21
+ /** The API's HTTP status, when the error restates an API failure. */
22
+ status;
23
+ constructor(message, options = {}) {
24
+ super(message);
25
+ this.name = "GenealogyError";
26
+ if (options.type !== void 0) this.type = options.type;
27
+ if (options.status !== void 0) this.status = options.status;
28
+ }
29
+ };
30
+ function isUuid(value) {
31
+ return UUID.test(value);
32
+ }
33
+ function path(...segments) {
34
+ return `${BASE}/${segments.map((s) => encodeURIComponent(s)).join("/")}`;
35
+ }
36
+ function requireUuid(label, value) {
37
+ const trimmed = value.trim();
38
+ if (!isUuid(trimmed)) throw new GenealogyError(`${label} "${value}" isn't a UUID.`);
39
+ return trimmed;
40
+ }
41
+ /** Parse a CLI number option; undefined stays undefined. */
42
+ function toPositiveInt(name, raw, max) {
43
+ if (raw === void 0) return void 0;
44
+ const value = typeof raw === "number" ? raw : Number(raw);
45
+ if (!Number.isInteger(value) || value < 1 || max != null && value > max) throw new GenealogyError(`${name} must be a whole number${max != null ? ` from 1 to ${max}` : ""}.`);
46
+ return value;
47
+ }
48
+ function requireDepth(raw) {
49
+ const depth = toPositiveInt("--depth", raw);
50
+ if (depth === void 0) throw new GenealogyError(`--depth is required: a relative depth from 1 to 7.`);
51
+ return depth;
52
+ }
53
+ function checkAsOf(asOf) {
54
+ if (asOf === void 0) return void 0;
55
+ if (Number.isNaN(Date.parse(asOf))) throw new GenealogyError(`--as-of "${asOf}" isn't an ISO 8601 timestamp, e.g. 2026-09-15T00:00:00Z.`);
56
+ return asOf;
57
+ }
58
+ async function listTrees(client) {
59
+ return (await client.get(path("trees"))).trees ?? [];
60
+ }
61
+ /** List the company's tree ids, keys and names. Unlike `listTrees`, this
62
+ * needs only members.view, not developer.view. */
63
+ async function listTreeKeys(client) {
64
+ return (await client.get(path("tree-keys"))).trees ?? [];
65
+ }
66
+ const isForbidden = (err) => err.status === 403;
67
+ const isNotFound = (err) => err.status === 404;
68
+ /** Tree keys need members.view; a caller with only developer.view reads
69
+ * them from the tree definitions instead. An API that predates the
70
+ * tree-keys endpoint answers 404, and also falls back. */
71
+ async function lookUpTreeKeys(client) {
72
+ try {
73
+ return await listTreeKeys(client);
74
+ } catch (err) {
75
+ if (!isForbidden(err) && !isNotFound(err)) throw err;
76
+ }
77
+ try {
78
+ return (await listTrees(client)).map(({ id, key, name }) => ({
79
+ id,
80
+ key,
81
+ name: name ?? key
82
+ }));
83
+ } catch (err) {
84
+ if (!isForbidden(err)) throw err;
85
+ throw new GenealogyError("Looking up a tree by key needs members.view or developer.view. Pass --tree <tree-uuid> instead.");
86
+ }
87
+ }
88
+ /** Resolve a tree key (e.g. "placement") or UUID to a tree UUID. Tree paths
89
+ * only take UUIDs, so a key is looked up in the company's tree keys. */
90
+ async function resolveTreeId(client, keyOrId) {
91
+ const trimmed = keyOrId.trim();
92
+ if (isUuid(trimmed)) return trimmed;
93
+ const trees = await lookUpTreeKeys(client);
94
+ const match = trees.find((tree) => tree.key === trimmed);
95
+ if (!match) throw new GenealogyError(`No tree with key "${trimmed}" on this company. Trees: ${trees.map((tree) => tree.key).join(", ") || "none"}.`);
96
+ return match.id;
97
+ }
98
+ const TREE_KINDS = [
99
+ "unilevel",
100
+ "binary",
101
+ "matrix"
102
+ ];
103
+ const TREE_ROLES = ["enrollment", "sponsor"];
104
+ const TREE_AUTHORITIES = [
105
+ "authoritative",
106
+ "replica",
107
+ "unconfigured"
108
+ ];
109
+ const MOVEMENT_APPROVALS = ["immediate", "require_approval"];
110
+ const VOLUME_ON_MOVE = ["follows_structure", "stays_at_earning"];
111
+ /** A tree's default rule: the placement rules plus the two role-tree rules. */
112
+ const TREE_PLACEMENT_RULES = [
113
+ "exact",
114
+ "first_empty",
115
+ "left",
116
+ "right",
117
+ "inside",
118
+ "outside",
119
+ "balanced",
120
+ "weak_leg",
121
+ "strong_leg",
122
+ "enroller_preference",
123
+ "defer",
124
+ "under_enroller",
125
+ "mirror_enrollment"
126
+ ];
127
+ const NONE = "none";
128
+ function oneOf(flag, value, allowed) {
129
+ if (!allowed.includes(value)) throw new GenealogyError(`${flag} must be one of ${allowed.join(", ")}.`);
130
+ return value;
131
+ }
132
+ function wholeNumber(flag, raw, min) {
133
+ const value = Number(raw);
134
+ if (raw.trim() === "" || !Number.isInteger(value) || value < min) throw new GenealogyError(`${flag} must be a whole number, ${min} or more.`);
135
+ return value;
136
+ }
137
+ function bool(flag, raw) {
138
+ if (raw === "true") return true;
139
+ if (raw === "false") return false;
140
+ throw new GenealogyError(`${flag} must be true or false.`);
141
+ }
142
+ function legList(raw) {
143
+ const legs = raw.split(",").map((leg) => leg.trim());
144
+ if (legs.some((leg) => leg === "") || new Set(legs).size !== legs.length) throw new GenealogyError("--leg-names must be distinct, nonblank names separated by commas, e.g. left,right.");
145
+ return legs;
146
+ }
147
+ /** Build the `tree` object a create or update sends, validating each flag.
148
+ * Only the flags given are sent. */
149
+ function treeFields(input) {
150
+ const fields = {};
151
+ const text = (flag, name, value) => {
152
+ if (value === void 0) return;
153
+ if (value.trim() === "") throw new GenealogyError(`${flag} can't be blank.`);
154
+ fields[name] = value;
155
+ };
156
+ const clearable = (name, value, parse) => {
157
+ if (value === void 0) return;
158
+ fields[name] = value === NONE ? null : parse(value);
159
+ };
160
+ text("--key", "key", input.key);
161
+ text("--name", "name", input.name);
162
+ if (input.kind !== void 0) fields["kind"] = oneOf("--kind", input.kind, TREE_KINDS);
163
+ clearable("role", input.role, (raw) => oneOf("--role", raw, TREE_ROLES));
164
+ if (input.legNames !== void 0) fields["leg_names"] = input.legNames === NONE ? [] : legList(input.legNames);
165
+ if (input.placementRule !== void 0) fields["default_placement_rule"] = oneOf("--placement-rule", input.placementRule, TREE_PLACEMENT_RULES);
166
+ if (input.movementApproval !== void 0) fields["movement_approval"] = oneOf("--movement-approval", input.movementApproval, MOVEMENT_APPROVALS);
167
+ if (input.maxDepth !== void 0) fields["max_depth"] = wholeNumber("--max-depth", input.maxDepth, 1);
168
+ for (const [prop, flag, name] of [
169
+ [
170
+ "holdingTankDays",
171
+ "--holding-tank-days",
172
+ "holding_tank_days"
173
+ ],
174
+ [
175
+ "movementWindowDays",
176
+ "--movement-window-days",
177
+ "movement_window_days"
178
+ ],
179
+ [
180
+ "maxMovementLevels",
181
+ "--max-movement-levels",
182
+ "max_movement_levels"
183
+ ]
184
+ ]) {
185
+ const raw = input[prop];
186
+ if (raw !== void 0) fields[name] = wholeNumber(flag, raw, 0);
187
+ }
188
+ clearable("volume_on_move", input.volumeOnMove, (raw) => oneOf("--volume-on-move", raw, VOLUME_ON_MOVE));
189
+ clearable("volume_key", input.volumeKey, (raw) => raw);
190
+ if (input.allowMultiplePositions !== void 0) fields["allow_multiple_positions"] = bool("--allow-multiple-positions", input.allowMultiplePositions);
191
+ if (input.authority !== void 0) fields["authority"] = oneOf("--authority", input.authority, TREE_AUTHORITIES);
192
+ clearable("owning_company_integration_id", input.owningCompanyIntegrationId, (raw) => wholeNumber("--owning-company-integration-id", raw, 1));
193
+ clearable("upstream_tree_ref", input.upstreamTreeRef, (raw) => raw);
194
+ clearable("external_integration_market_id", input.externalIntegrationMarketId, (raw) => raw);
195
+ return fields;
196
+ }
197
+ /** The body for POST /trees. Checks what the API would refuse anyway, so a
198
+ * mistake is named before anything is sent. */
199
+ function createTreeBody(input) {
200
+ const fields = treeFields(input);
201
+ const missing = [
202
+ ["key", "--key"],
203
+ ["name", "--name"],
204
+ ["kind", "--kind"],
205
+ ["authority", "--authority"]
206
+ ].filter(([name]) => fields[name] === void 0).map(([, flag]) => flag);
207
+ if (missing.length > 0) throw new GenealogyError(`Creating a tree needs ${missing.join(", ")}. --authority declares who owns the tree: authoritative (Fluid), replica (an integration's copy) or unconfigured (not yet decided; it takes no placements).`);
208
+ const kind = fields["kind"];
209
+ const legs = fields["leg_names"];
210
+ if (kind === "unilevel" && legs && legs.length > 0) throw new GenealogyError("A unilevel tree has no legs; drop --leg-names.");
211
+ if (kind === "binary" && legs && legs.length !== 2) throw new GenealogyError("A binary tree has exactly two legs, e.g. --leg-names left,right (the default).");
212
+ if (kind === "matrix" && (!legs || legs.length < 2)) throw new GenealogyError("A matrix tree needs --leg-names with two or more legs; their count is its width.");
213
+ if (kind !== "unilevel" && fields["volume_on_move"] == null) throw new GenealogyError(`A ${String(kind)} tree needs --volume-on-move ${VOLUME_ON_MOVE.join(" or ")}.`);
214
+ if (fields["authority"] === "replica" && fields["owning_company_integration_id"] == null) throw new GenealogyError("A replica tree needs --owning-company-integration-id.");
215
+ return { tree: fields };
216
+ }
217
+ /** Readable restatements of the registry's write failures. */
218
+ function treeWriteError(err, tree, method) {
219
+ const status = err.status;
220
+ const nested = err.body?.["error"];
221
+ const type = nested && typeof nested === "object" ? nested.details?.type : void 0;
222
+ if (typeof status !== "number" || typeof type !== "string") return err;
223
+ const message = {
224
+ duplicate_key: `Another tree on this company already has the key of "${tree}". Tree keys are unique per company; pick another --key.`,
225
+ role_taken: "This company already has a tree with that role. Each role (enrollment, sponsor) has one tree.",
226
+ stale_tree: `Tree "${tree}" changed since its lock_version was read. Read it again with "fluid genealogy trees" and retry, or omit --lock-version to use the current one.`,
227
+ tree_has_nodes: method === "DELETE" ? `Tree "${tree}" has seats, and delete refuses a tree with nodes. Remove its seats first.` : `Tree "${tree}" has seats, so only its --name can change; other fields need a structural migration.`,
228
+ reserved_role: "The default enrollment and placement trees can't be deleted or have their role changed.",
229
+ reserved_key: "The keys \"enrollment\" and \"placement\" belong to the default role trees."
230
+ }[type];
231
+ return message ? new GenealogyError(message, {
232
+ type,
233
+ status
234
+ }) : err;
235
+ }
236
+ /** A tree's full definition by key or UUID, from the tree list. */
237
+ async function findTree(client, keyOrId) {
238
+ const wanted = keyOrId.trim();
239
+ const trees = await listTrees(client);
240
+ const match = trees.find((tree) => isUuid(wanted) ? tree.id === wanted : tree.key === wanted);
241
+ if (!match) throw new GenealogyError(`No tree "${wanted}" on this company. Trees: ${trees.map((tree) => tree.key).join(", ") || "none"}.`);
242
+ return match;
243
+ }
244
+ function planCreateTree(input) {
245
+ return {
246
+ method: "POST",
247
+ path: path("trees"),
248
+ body: createTreeBody(input)
249
+ };
250
+ }
251
+ /** Plan a PATCH: only the given fields, at the tree's lock_version (read
252
+ * now unless --lock-version pins it). */
253
+ async function planUpdateTree(client, keyOrId, input, lockVersion) {
254
+ const fields = treeFields(input);
255
+ if (Object.keys(fields).length === 0) throw new GenealogyError("Pass at least one field to change, e.g. --name or --movement-approval.");
256
+ const { id, version } = await treeVersion(client, keyOrId, lockVersion);
257
+ return {
258
+ method: "PATCH",
259
+ path: path("trees", id),
260
+ body: { tree: {
261
+ ...fields,
262
+ lock_version: version
263
+ } }
264
+ };
265
+ }
266
+ async function planDeleteTree(client, keyOrId, lockVersion) {
267
+ const { id, version } = await treeVersion(client, keyOrId, lockVersion);
268
+ return {
269
+ method: "DELETE",
270
+ path: path("trees", id),
271
+ params: { lock_version: version }
272
+ };
273
+ }
274
+ async function treeVersion(client, keyOrId, lockVersion) {
275
+ const pinned = lockVersion === void 0 ? void 0 : wholeNumber("--lock-version", lockVersion, 0);
276
+ if (pinned !== void 0 && isUuid(keyOrId.trim())) return {
277
+ id: keyOrId.trim(),
278
+ version: pinned
279
+ };
280
+ const tree = await findTree(client, keyOrId);
281
+ const current = tree["lock_version"];
282
+ if (pinned === void 0 && typeof current !== "number") throw new GenealogyError(`Tree "${keyOrId}" was listed without a lock_version; pass --lock-version.`);
283
+ return {
284
+ id: tree.id,
285
+ version: pinned ?? current
286
+ };
287
+ }
288
+ /** Send a planned tree write. Returns the written tree, or a deletion
289
+ * receipt. */
290
+ async function sendTreeWrite(client, write, label) {
291
+ try {
292
+ if (write.method === "POST") return withoutMeta(await client.post(write.path, write.body));
293
+ if (write.method === "PATCH") return withoutMeta(await client.patch(write.path, write.body));
294
+ await client.delete(write.path, { params: write.params });
295
+ return {
296
+ deleted: true,
297
+ tree: label,
298
+ path: write.path
299
+ };
300
+ } catch (err) {
301
+ throw treeWriteError(err, label, write.method);
302
+ }
303
+ }
304
+ const MIRROR_RUN_STATUSES = [
305
+ "queued",
306
+ "running",
307
+ "succeeded",
308
+ "failed",
309
+ "halted"
310
+ ];
311
+ /** Whether a run has finished, one way or another. */
312
+ function mirrorRunFinished(run) {
313
+ return [
314
+ "succeeded",
315
+ "failed",
316
+ "halted"
317
+ ].includes(run.status);
318
+ }
319
+ /** Preview the mirror of the enrollment tree into the placement tree:
320
+ * what would be seated and re-parented. Writes nothing. */
321
+ async function previewMirror(client, treeId) {
322
+ return { mirror: (await client.post(path("trees", treeId, "mirror-enrollment"), { mirror: { dry_run: true } })).mirror ?? null };
323
+ }
324
+ /** Queue a background run that mirrors the enrollment tree into the
325
+ * placement tree, or get the run already active (`started: false`). */
326
+ async function startMirror(client, treeId) {
327
+ const data = await client.post(path("trees", treeId, "mirror-enrollment"), { mirror: { dry_run: false } });
328
+ return {
329
+ mirror_run: data.mirror_run ?? null,
330
+ started: data.started === true
331
+ };
332
+ }
333
+ async function getMirrorRun(client, id) {
334
+ return (await client.get(path("mirror-runs", requireUuid("Mirror run", id)))).mirror_run ?? null;
335
+ }
336
+ /** The company's most recent mirror runs, newest first. */
337
+ async function listMirrorRuns(client, options = {}) {
338
+ if (options.status !== void 0 && !MIRROR_RUN_STATUSES.includes(options.status)) throw new GenealogyError(`--status must be one of ${MIRROR_RUN_STATUSES.join(", ")}`);
339
+ const params = {};
340
+ if (options.status !== void 0) params["status"] = options.status;
341
+ if (options.limit !== void 0) params["limit"] = options.limit;
342
+ return { mirror_runs: (await client.get(path("mirror-runs"), params)).mirror_runs ?? [] };
343
+ }
344
+ /** Poll a run until it finishes, reporting each reading. */
345
+ async function waitForMirrorRun(client, run, options) {
346
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
347
+ let current = run;
348
+ options.onProgress?.(current);
349
+ while (!mirrorRunFinished(current)) {
350
+ await sleep(options.intervalMs);
351
+ const next = await getMirrorRun(client, current.id);
352
+ if (!next) throw new GenealogyError(`Mirror run ${current.id} is gone`);
353
+ current = next;
354
+ options.onProgress?.(current);
355
+ }
356
+ return current;
357
+ }
358
+ /** Find a member's one seat in a tree. Seats are listed across every tree,
359
+ * so the tree is filtered here; a member with several positions in the
360
+ * tree must be addressed by node id. */
361
+ async function resolveMemberNode(client, memberId, tree = DEFAULT_TREE) {
362
+ const member = requireUuid("Member", memberId);
363
+ const treeId = await resolveTreeId(client, tree);
364
+ const { items } = await collectPages(client, path("members", member, "nodes"), {}, "nodes", { maxPageSize: NODE_PAGE_MAX });
365
+ const seats = items.filter((node) => node.tree_id === treeId);
366
+ if (seats.length === 0) throw new GenealogyError(`Member ${member} has no seat in tree "${tree}".`);
367
+ if (seats.length > 1) {
368
+ const listed = seats.map((seat) => `${seat.id} (position ${seat.position})`).join(", ");
369
+ throw new GenealogyError(`Member ${member} has ${seats.length} seats in tree "${tree}": ${listed}. Pass the node id instead.`);
370
+ }
371
+ return seats[0].id;
372
+ }
373
+ /** A command's subject: a node UUID, or --member resolved in --tree. */
374
+ async function resolveSubject(client, node, member, tree = DEFAULT_TREE) {
375
+ if (node && member) throw new GenealogyError("Pass a node id or --member, not both.");
376
+ if (node) return requireUuid("Node", node);
377
+ if (member) return resolveMemberNode(client, member, tree);
378
+ throw new GenealogyError("Pass a node id or --member <member-id>.");
379
+ }
380
+ /** Follow next_cursor until the list ends or --limit rows are collected.
381
+ * A capped read reports the cursor to resume from; an uncapped read that
382
+ * runs past MAX_PAGES fails rather than return part of the list. */
383
+ async function collectPages(client, endpoint, params, itemsKey, options) {
384
+ const pageSize = toPositiveInt("--page-size", options.pageSize, options.maxPageSize) ?? options.maxPageSize;
385
+ const limit = toPositiveInt("--limit", options.limit);
386
+ const items = [];
387
+ const pages = [];
388
+ let cursor = options.cursor;
389
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
390
+ const remaining = limit === void 0 ? pageSize : limit - items.length;
391
+ const data = await client.get(endpoint, {
392
+ ...params,
393
+ limit: Math.min(pageSize, remaining),
394
+ ...cursor ? { cursor } : {}
395
+ });
396
+ pages.push(data);
397
+ const rows = data[itemsKey];
398
+ if (Array.isArray(rows)) items.push(...rows);
399
+ const next = data.meta?.pagination?.next_cursor ?? null;
400
+ if (!next) return {
401
+ items,
402
+ nextCursor: null,
403
+ pages
404
+ };
405
+ if (limit !== void 0 && items.length >= limit) return {
406
+ items,
407
+ nextCursor: next,
408
+ pages
409
+ };
410
+ cursor = next;
411
+ }
412
+ throw new GenealogyError(`More than ${MAX_PAGES} pages; refusing to return a partial list. Pass --limit and resume with --cursor.`);
413
+ }
414
+ /** Paged node reads and the query selectors each one accepts. The API
415
+ * refuses selectors it does not list, so only these are sent. */
416
+ const NODE_PAGES = {
417
+ children: {
418
+ asOf: false,
419
+ depth: false,
420
+ idsOnly: true
421
+ },
422
+ siblings: {
423
+ asOf: false,
424
+ depth: false,
425
+ idsOnly: true
426
+ },
427
+ frontline: {
428
+ asOf: false,
429
+ depth: false,
430
+ idsOnly: true
431
+ },
432
+ "personally-enrolled": {
433
+ asOf: false,
434
+ depth: false,
435
+ idsOnly: true
436
+ },
437
+ ancestors: {
438
+ asOf: true,
439
+ depth: false,
440
+ idsOnly: false
441
+ },
442
+ descendants: {
443
+ asOf: true,
444
+ depth: true,
445
+ idsOnly: true
446
+ },
447
+ subtree: {
448
+ asOf: true,
449
+ depth: true,
450
+ idsOnly: true
451
+ },
452
+ level: {
453
+ asOf: true,
454
+ depth: true,
455
+ idsOnly: true
456
+ }
457
+ };
458
+ async function readNodePage(client, nodeId, operation, options = {}) {
459
+ const accepts = NODE_PAGES[operation];
460
+ if (options.asOf !== void 0 && !accepts.asOf) throw new GenealogyError(`--as-of is not accepted by ${operation}.`);
461
+ if (options.idsOnly && !accepts.idsOnly) throw new GenealogyError(`--ids-only is not accepted by ${operation}.`);
462
+ const params = {};
463
+ if (accepts.depth) params["depth"] = requireDepth(options.depth);
464
+ const asOf = checkAsOf(options.asOf);
465
+ if (asOf) params["as_of"] = asOf;
466
+ if (options.idsOnly) params["ids_only"] = true;
467
+ const itemsKey = options.idsOnly ? "ids" : "nodes";
468
+ const { items, nextCursor, pages } = await collectPages(client, path("nodes", nodeId, operation), params, itemsKey, {
469
+ ...options,
470
+ maxPageSize: NODE_PAGE_MAX
471
+ });
472
+ const result = {
473
+ [itemsKey]: items,
474
+ next_cursor: nextCursor
475
+ };
476
+ if (operation === "frontline" || operation === "personally-enrolled") result.node = pages[0]?.node ?? null;
477
+ if (accepts.depth) result.depth_truncated = pages.some((page) => page.meta?.traversal?.depth_truncated === true);
478
+ return result;
479
+ }
480
+ /** Grandchildren are the nodes exactly two levels down. */
481
+ function readGrandchildren(client, nodeId, options = {}) {
482
+ return readNodePage(client, nodeId, "level", {
483
+ ...options,
484
+ depth: 2
485
+ });
486
+ }
487
+ /** One node; current reads include children_count and downline_count,
488
+ * as_of reads return the recorded placement without counts. */
489
+ async function readNode(client, nodeId, asOf) {
490
+ const at = checkAsOf(asOf);
491
+ return { node: (await client.get(path("nodes", nodeId), at ? { as_of: at } : void 0)).node ?? null };
492
+ }
493
+ /** Structural counts. They include retained seats of discarded members,
494
+ * so they can exceed what the page reads list. */
495
+ async function readCounts(client, nodeId) {
496
+ const { node } = await readNode(client, nodeId);
497
+ if (!node || typeof node.children_count !== "number" || typeof node.downline_count !== "number") throw new GenealogyError(`The read of node ${nodeId} returned no counts.`);
498
+ return {
499
+ node_id: node.id,
500
+ children_count: node.children_count,
501
+ downline_count: node.downline_count,
502
+ counts_as_of: node.counts_as_of ?? null
503
+ };
504
+ }
505
+ /** parent and root take as_of; sponsor and enroller take no selectors.
506
+ * The node is null for a root (and for a role-tree root). */
507
+ async function readRelative(client, nodeId, operation, asOf) {
508
+ if (asOf !== void 0 && (operation === "sponsor" || operation === "enroller")) throw new GenealogyError(`--as-of is not accepted by ${operation}.`);
509
+ const at = checkAsOf(asOf);
510
+ return { node: (await client.get(path("nodes", nodeId, operation), at ? { as_of: at } : void 0)).node ?? null };
511
+ }
512
+ const RELATION_KINDS = [
513
+ "ancestor_of",
514
+ "descendant_of",
515
+ "parent_of",
516
+ "child_of",
517
+ "sibling_of",
518
+ "root_of",
519
+ "indirect_of",
520
+ "in_subtree_of",
521
+ "within_downline"
522
+ ];
523
+ /** Answer how node relates to other: one kind, or every kind when none is
524
+ * given. For within_downline, other is the downline's root. */
525
+ async function readRelation(client, nodeId, otherId, kind) {
526
+ const other = requireUuid("Other node", otherId);
527
+ if (kind !== void 0 && !RELATION_KINDS.includes(kind)) throw new GenealogyError(`--kind must be one of ${RELATION_KINDS.join(", ")}.`);
528
+ const answers = await mapLimit(kind ? [kind] : [...RELATION_KINDS], CONCURRENCY, async (k) => {
529
+ return [k, (await client.get(path("nodes", nodeId, "relation"), {
530
+ other,
531
+ kind: k
532
+ })).relation?.answer ?? false];
533
+ });
534
+ return {
535
+ node_id: nodeId,
536
+ other,
537
+ relations: Object.fromEntries(answers)
538
+ };
539
+ }
540
+ async function readLevelCounts(client, nodeId, depth) {
541
+ const data = await client.get(path("nodes", nodeId, "level-counts"), { depth: requireDepth(depth) });
542
+ return {
543
+ node_id: nodeId,
544
+ levels: data.levels ?? [],
545
+ counts_as_of: data.meta?.traversal?.counts_as_of ?? null,
546
+ depth_truncated: data.meta?.traversal?.depth_truncated ?? false
547
+ };
548
+ }
549
+ async function readPlacements(client, nodeId, options = {}) {
550
+ const { items, nextCursor } = await collectPages(client, path("nodes", nodeId, "placements"), {}, "placements", {
551
+ ...options,
552
+ maxPageSize: HISTORY_PAGE_MAX
553
+ });
554
+ return {
555
+ placements: items,
556
+ next_cursor: nextCursor
557
+ };
558
+ }
559
+ async function readHoldingTank(client, treeId, options = {}) {
560
+ const { items, nextCursor } = await collectPages(client, path("trees", treeId, "holding-tank"), {}, "nodes", {
561
+ ...options,
562
+ maxPageSize: HISTORY_PAGE_MAX
563
+ });
564
+ return {
565
+ nodes: items,
566
+ next_cursor: nextCursor
567
+ };
568
+ }
569
+ async function listMoveRequests(client, treeId, status, options = {}) {
570
+ const { items, nextCursor } = await collectPages(client, path("trees", treeId, "move-requests"), status ? { status } : {}, "move_requests", {
571
+ ...options,
572
+ maxPageSize: HISTORY_PAGE_MAX
573
+ });
574
+ return {
575
+ move_requests: items,
576
+ next_cursor: nextCursor
577
+ };
578
+ }
579
+ async function listMoveJobs(client, treeId, status, options = {}) {
580
+ const { items, nextCursor } = await collectPages(client, path("trees", treeId, "move-jobs"), status ? { status } : {}, "move_jobs", {
581
+ ...options,
582
+ maxPageSize: HISTORY_PAGE_MAX
583
+ });
584
+ return {
585
+ move_jobs: items,
586
+ next_cursor: nextCursor
587
+ };
588
+ }
589
+ async function getMoveRequest(client, id) {
590
+ return (await client.get(path("move-requests", requireUuid("Move request", id)))).move_request ?? null;
591
+ }
592
+ async function getMoveJob(client, id) {
593
+ return (await client.get(path("move-jobs", requireUuid("Move job", id)))).move_job ?? null;
594
+ }
595
+ const LEG_MEASURE = "headcount";
596
+ const LEG_COUNTED = "seats: child plus downline_count";
597
+ const LEG_NOTE = "Headcount only; the genealogy API has no leg volume. Counts include retained seats of discarded members.";
598
+ /** Largest first; ties keep the tree's leg order, then node id. */
599
+ function rankLegs(entries, legNames = []) {
600
+ const order = (leg) => {
601
+ const index = leg === null ? -1 : legNames.indexOf(leg);
602
+ return index === -1 ? legNames.length : index;
603
+ };
604
+ return [...entries].sort((a, b) => b.seats - a.seats || order(a.leg) - order(b.leg) || (a.node_id ?? "").localeCompare(b.node_id ?? ""));
605
+ }
606
+ /** Each direct child as a leg with its headcount, largest first. Named legs
607
+ * of a binary or matrix tree with no child are listed with 0 seats. */
608
+ async function readLegs(client, nodeId, options = {}) {
609
+ const maxChildren = options.maxChildren ?? 200;
610
+ const [trees, settled] = await Promise.all([listTrees(client).catch((err) => {
611
+ if (err.status === 403) return null;
612
+ throw err;
613
+ }), readSettledLegs(client, nodeId, maxChildren)]);
614
+ const { anchor, counted } = settled;
615
+ const treeId = anchor?.tree_id ?? null;
616
+ const legNames = trees?.find((tree) => tree.id === treeId)?.leg_names ?? [];
617
+ const occupied = new Set(counted.map((entry) => entry.leg));
618
+ const free = legNames.filter((leg) => !occupied.has(leg));
619
+ const hidden = hiddenChildren(anchor, counted);
620
+ let unlisted;
621
+ const entries = [...counted];
622
+ if (hidden) {
623
+ const unknownEmpty = Math.max(0, free.length - hidden.children);
624
+ const ranked = hidden.children === 1;
625
+ if (ranked) entries.push({
626
+ leg: free.length === 1 ? free[0] ?? null : null,
627
+ node_id: null,
628
+ member_id: null,
629
+ downline_count: hidden.seats - 1,
630
+ seats: hidden.seats,
631
+ counts_as_of: anchor?.counts_as_of ?? null,
632
+ unlisted: true
633
+ });
634
+ unlisted = {
635
+ ...hidden,
636
+ ranked,
637
+ unknown_empty_legs: unknownEmpty
638
+ };
639
+ } else for (const leg of free) entries.push({
640
+ leg,
641
+ node_id: null,
642
+ member_id: null,
643
+ downline_count: 0,
644
+ seats: 0,
645
+ counts_as_of: null
646
+ });
647
+ return {
648
+ measure: LEG_MEASURE,
649
+ counted: LEG_COUNTED,
650
+ node_id: nodeId,
651
+ tree_id: treeId,
652
+ legs: rankLegs(entries, legNames),
653
+ ...unlisted ? { unlisted } : {},
654
+ leg_names_known: trees !== null,
655
+ note: [
656
+ LEG_NOTE,
657
+ unlisted ? unlistedNote(unlisted) : null,
658
+ trees === null ? "Empty named legs are not listed: reading leg names needs developer.view." : null
659
+ ].filter(Boolean).join(" ")
660
+ };
661
+ }
662
+ function unlistedNote(unlisted) {
663
+ const { children, seats } = unlisted;
664
+ const what = `${children} direct ${children === 1 ? "child holds" : "children hold"} a discarded member's seat, which the children read leaves out`;
665
+ if (unlisted.ranked) return `${what}; it is listed with unlisted: true and its ${seats} ${seats === 1 ? "seat" : "seats"} from the node's counts.${unlisted.unknown_empty_legs > 0 ? " Empty named legs are not listed, since one of them is the leg it holds." : ""}`;
666
+ return `${what}; their ${seats} seats are not ranked, and empty named legs are not listed because one may hold them.`;
667
+ }
668
+ /** Reads that bracket the leg reads and disagree mean the tree changed
669
+ * meanwhile; read again this many times. */
670
+ const LEG_READ_ATTEMPTS = 3;
671
+ /** The node's children and each child's counts, read twice between reads
672
+ * of the node's counts, accepted only when every read agrees. The API has
673
+ * no read that returns them together, so agreement is what shows that no
674
+ * child moved in, out or between legs while they were read. */
675
+ async function readSettledLegs(client, nodeId, maxChildren) {
676
+ for (let attempt = 1;; attempt += 1) {
677
+ const { node: before } = await readNode(client, nodeId);
678
+ const first = await readLegPass(client, nodeId, maxChildren);
679
+ const second = await readLegPass(client, nodeId, maxChildren);
680
+ const { node: after } = await readNode(client, nodeId);
681
+ if (sameCounts(before, after) && legKey(first) === legKey(second)) return {
682
+ anchor: after,
683
+ counted: second
684
+ };
685
+ if (attempt >= LEG_READ_ATTEMPTS) throw new GenealogyError(`Node ${nodeId}'s legs changed during each of ${LEG_READ_ATTEMPTS} reads, so they could not be ranked. Try again.`);
686
+ }
687
+ }
688
+ /** One read of the children and of each child's counts. */
689
+ async function readLegPass(client, nodeId, maxChildren) {
690
+ const page = await collectPages(client, path("nodes", nodeId, "children"), {}, "nodes", {
691
+ limit: maxChildren + 1,
692
+ maxPageSize: NODE_PAGE_MAX
693
+ });
694
+ if (page.items.length > maxChildren) throw new GenealogyError(`Node ${nodeId} has more than ${maxChildren} direct children, and ranking legs reads each child's counts with its own request. Pass --limit <n> to count up to n children.`);
695
+ return mapLimit(page.items, CONCURRENCY, (child) => readCounts(client, child.id).then((counts) => ({
696
+ leg: child.leg,
697
+ node_id: child.id,
698
+ member_id: child.member_id,
699
+ downline_count: counts.downline_count,
700
+ seats: counts.downline_count + 1,
701
+ counts_as_of: counts.counts_as_of
702
+ })));
703
+ }
704
+ function sameCounts(a, b) {
705
+ return a?.children_count === b?.children_count && a?.downline_count === b?.downline_count;
706
+ }
707
+ /** Who holds which leg and how many seats, in a stable order. */
708
+ function legKey(entries) {
709
+ return entries.map((entry) => `${entry.node_id}:${entry.leg}:${entry.seats}`).sort().join("|");
710
+ }
711
+ /** The node's structural counts include seats of discarded members, which
712
+ * the children read omits; the difference is what was left out. */
713
+ function hiddenChildren(anchor, counted) {
714
+ if (typeof anchor?.children_count !== "number") return void 0;
715
+ const children = anchor.children_count - counted.length;
716
+ if (children <= 0) return void 0;
717
+ const rankedSeats = counted.reduce((sum, entry) => sum + entry.seats, 0);
718
+ return {
719
+ children,
720
+ seats: typeof anchor.downline_count === "number" ? Math.max(children, anchor.downline_count - rankedSeats) : children
721
+ };
722
+ }
723
+ /** The strong leg is the largest by headcount, the weak leg the smallest.
724
+ * With unlisted children a leg is named only when they cannot change the
725
+ * pick: one unlisted child is ranked with its exact size; with more, only
726
+ * a strong leg that outnumbers all their seats together is certain. A
727
+ * weak pick is withheld while an empty leg cannot be named, including
728
+ * when the tree's leg names could not be read. */
729
+ function pickLeg(report, which) {
730
+ const legs = which === "strong" ? report.legs : [...report.legs].reverse();
731
+ const top = legs[0] ?? null;
732
+ const base = {
733
+ measure: report.measure,
734
+ counted: report.counted,
735
+ node_id: report.node_id
736
+ };
737
+ const tiedWith = (leg) => legs.slice(1).filter((other) => other.seats === leg.seats);
738
+ const { unlisted } = report;
739
+ if (which === "strong" ? !unlisted || unlisted.ranked || top !== null && top.seats > unlisted.seats : report.leg_names_known && (!unlisted || unlisted.ranked && unlisted.unknown_empty_legs === 0)) return {
740
+ ...base,
741
+ leg: top,
742
+ tied_with: top ? tiedWith(top) : [],
743
+ ...unlisted ? { unlisted } : {},
744
+ note: top ? report.note : "This node has no legs."
745
+ };
746
+ const reason = !report.leg_names_known ? "the tree's leg names could not be read, so an empty named leg may be smaller" : unlisted?.ranked ? "an empty named leg is smaller, but it cannot be told from the leg the unlisted child holds" : "it may be one that is not listed";
747
+ return {
748
+ ...base,
749
+ leg: null,
750
+ tied_with: [],
751
+ ...unlisted ? { unlisted } : {},
752
+ best_listed: top,
753
+ note: `${report.note} The ${which} leg cannot be told: ${reason}.${top ? " best_listed is the pick among the listed legs." : ""}`
754
+ };
755
+ }
756
+ /** Read the subtree to depth (1-7) for `tree`; every page is followed. */
757
+ async function readTree(client, nodeId, depth) {
758
+ const bounded = toPositiveInt("--depth", depth ?? 3, 7);
759
+ const result = await readNodePage(client, nodeId, "subtree", { depth: bounded });
760
+ return {
761
+ nodes: result.nodes ?? [],
762
+ depthTruncated: result.depth_truncated ?? false,
763
+ depth: bounded
764
+ };
765
+ }
766
+ function label(node) {
767
+ const leg = node.leg ? ` [${node.leg}]` : "";
768
+ return `member ${node.member_id}${leg} (node ${node.id})`;
769
+ }
770
+ /** Indent a subtree page (anchor at relative depth 0) as a text tree. */
771
+ function renderTree(nodes, depthTruncated, depth) {
772
+ const anchor = nodes.find((node) => node.relative_depth === 0) ?? nodes[0];
773
+ if (!anchor) return "(no nodes)\n";
774
+ const children = /* @__PURE__ */ new Map();
775
+ for (const node of nodes) {
776
+ if (node === anchor || !node.parent_node_id) continue;
777
+ const siblings = children.get(node.parent_node_id) ?? [];
778
+ siblings.push(node);
779
+ children.set(node.parent_node_id, siblings);
780
+ }
781
+ const lines = [label(anchor)];
782
+ const walk = (parentId, prefix) => {
783
+ const kids = children.get(parentId) ?? [];
784
+ kids.forEach((kid, index) => {
785
+ const last = index === kids.length - 1;
786
+ lines.push(`${prefix}${last ? "└── " : "├── "}${label(kid)}`);
787
+ walk(kid.id, `${prefix}${last ? " " : "│ "}`);
788
+ });
789
+ };
790
+ walk(anchor.id, "");
791
+ if (depthTruncated) lines.push(`… more seats below depth ${depth} (depth_truncated)`);
792
+ return `${lines.join("\n")}\n`;
793
+ }
794
+ const PLACEMENT_RULES = [
795
+ "exact",
796
+ "first_empty",
797
+ "left",
798
+ "right",
799
+ "inside",
800
+ "outside",
801
+ "balanced",
802
+ "weak_leg",
803
+ "strong_leg",
804
+ "enroller_preference",
805
+ "defer"
806
+ ];
807
+ function placementFields(input) {
808
+ if (input.rule !== void 0 && !PLACEMENT_RULES.includes(input.rule)) throw new GenealogyError(`--rule must be one of ${PLACEMENT_RULES.join(", ")}.`);
809
+ const fields = {};
810
+ if (input.parent !== void 0) fields["parent_node_id"] = input.parent === null ? null : requireUuid("Parent node", input.parent);
811
+ if (input.leg !== void 0) fields["leg"] = input.leg;
812
+ if (input.rule !== void 0) fields["rule"] = input.rule;
813
+ if (input.reason !== void 0) fields["reason"] = input.reason;
814
+ return fields;
815
+ }
816
+ function withoutMeta(data) {
817
+ if (data && typeof data === "object") {
818
+ const { meta: _meta, ...rest } = data;
819
+ return rest;
820
+ }
821
+ return data;
822
+ }
823
+ /** Place a seat waiting in the holding tank. */
824
+ async function placeHeldSeat(client, nodeId, input) {
825
+ if (input.rule === "defer") throw new GenealogyError("A held seat can't be deferred again.");
826
+ if (input.parent === void 0) throw new GenealogyError("Pass --parent <node> (or --root) for the seat.");
827
+ return withoutMeta(await client.post(path("nodes", nodeId, "place"), { placement: placementFields(input) }));
828
+ }
829
+ /** Give a member a new seat in a binary or matrix tree, or hold it in the
830
+ * holding tank with the rule defer. */
831
+ async function placeMember(client, treeId, memberId, input) {
832
+ const member = requireUuid("Member", memberId);
833
+ const position = input.position;
834
+ if (position !== void 0 && !(Number.isInteger(position) && position >= 0)) throw new GenealogyError("--position must be a whole number, 0 or more.");
835
+ if (input.rule === "defer") {
836
+ if (input.parent !== void 0 || input.leg !== void 0 || input.reason !== void 0) throw new GenealogyError("The rule defer takes no --parent, --root, --leg or --reason; give the reason when the held seat is placed.");
837
+ } else if (input.parent === void 0) throw new GenealogyError("Pass --parent <node> (or --root), or --rule defer to hold the seat.");
838
+ return withoutMeta(await client.post(path("trees", treeId, "nodes"), { node: {
839
+ member_id: member,
840
+ ...placementFields(input),
841
+ ...position !== void 0 ? { position } : {}
842
+ } }));
843
+ }
844
+ function moveBody(input) {
845
+ return { move: {
846
+ parent_node_id: input.parent === null ? null : requireUuid("Parent node", input.parent),
847
+ ...input.leg !== void 0 ? { leg: input.leg } : {},
848
+ ...input.reason !== void 0 ? { reason: input.reason } : {}
849
+ } };
850
+ }
851
+ /** Preview a move; writes and enqueues nothing. */
852
+ async function validateMove(client, nodeId, input) {
853
+ return { validation: (await client.post(path("nodes", nodeId, "move", "validate"), moveBody(input))).validation ?? null };
854
+ }
855
+ /** Request a move. It applies now (status 200), or returns a move request
856
+ * awaiting approval or a deferred move job (status 202). */
857
+ async function requestMove(client, nodeId, input, idempotencyKey) {
858
+ return withoutMeta(await client.post(path("nodes", nodeId, "move"), moveBody(input), idempotencyKey ? { headers: { "Idempotency-Key": idempotencyKey } } : void 0));
859
+ }
860
+ async function approveMoveRequest(client, id, reason) {
861
+ return withoutMeta(await client.post(path("move-requests", requireUuid("Move request", id), "approve"), reason !== void 0 ? { decision: { reason } } : void 0));
862
+ }
863
+ async function rejectMoveRequest(client, id, reason) {
864
+ if (!reason || !/\S/.test(reason)) throw new GenealogyError("Rejecting a move request needs --reason <text>.");
865
+ return withoutMeta(await client.post(path("move-requests", requireUuid("Move request", id), "reject"), { decision: { reason } }));
866
+ }
867
+ /** Map with at most `limit` calls in flight, keeping input order. */
868
+ async function mapLimit(items, limit, fn) {
869
+ const results = [];
870
+ let next = 0;
871
+ const worker = async () => {
872
+ while (next < items.length) {
873
+ const index = next;
874
+ next += 1;
875
+ results[index] = await fn(items[index]);
876
+ }
877
+ };
878
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
879
+ return results;
880
+ }
881
+ //#endregion
882
+ //#region src/index.ts
883
+ async function client(ctx) {
884
+ return await ctx.getClient();
885
+ }
886
+ /**
887
+ * Placements and moves change the live tree and need an explicit yes.
888
+ * Scripts and agents pass `--yes`; an interactive terminal is asked. With
889
+ * neither, nothing runs.
890
+ */
891
+ async function confirmChange(question, yes, interactive = Boolean(process.stdin.isTTY)) {
892
+ if (yes) return;
893
+ if (!interactive) throw new GenealogyError(`${question} Pass --yes to confirm.`);
894
+ if (!(await prompts({
895
+ type: "confirm",
896
+ name: "ok",
897
+ message: question,
898
+ initial: false
899
+ })).ok) throw new GenealogyError("Cancelled.");
900
+ }
901
+ /** Print a failure as JSON on stderr and exit non-zero, so scripts and
902
+ * agents can read it the same way as a success. Genealogy errors carry
903
+ * `error.message` and a machine-readable `error.details.type`. */
904
+ function fail(err) {
905
+ const status = typeof err.status === "number" ? err.status : void 0;
906
+ const body = err.body;
907
+ const nested = body?.["error"];
908
+ const fromBody = [
909
+ body?.["error_message"],
910
+ typeof nested === "object" ? nested?.message : nested,
911
+ body?.["message"]
912
+ ].find((c) => typeof c === "string");
913
+ const restated = err instanceof GenealogyError;
914
+ const message = restated ? err.message : fromBody ?? (err instanceof Error ? err.message : String(err));
915
+ const details = typeof nested === "object" && nested?.details ? nested.details : void 0;
916
+ const type = restated && err.type ? err.type : typeof details?.["type"] === "string" ? details["type"] : void 0;
917
+ const { type: _type, ...reasons } = details ?? {};
918
+ process.stderr.write(JSON.stringify({
919
+ error: message,
920
+ ...type ? { type } : {},
921
+ ...status ? { status } : {},
922
+ ...Object.keys(reasons).length > 0 ? { details: reasons } : {}
923
+ }) + "\n");
924
+ process.exitCode = 1;
925
+ }
926
+ /** One stderr line per reading while --wait follows a mirror run. */
927
+ function reportMirrorProgress(mirrorRun) {
928
+ const counts = Object.entries(mirrorRun.counts).map(([outcome, n]) => `${outcome} ${n}`).join(", ");
929
+ const state = mirrorRunFinished(mirrorRun) ? "finished" : "progress";
930
+ process.stderr.write(`mirror run ${mirrorRun.id} ${state}: ${mirrorRun.status}, window ${mirrorRun.windows}${counts ? `, ${counts}` : ""}\n`);
931
+ }
932
+ function run(action) {
933
+ return action().catch(fail);
934
+ }
935
+ function paging(opts) {
936
+ return {
937
+ limit: toPositiveInt("--limit", opts.limit),
938
+ pageSize: toPositiveInt("--page-size", opts.pageSize),
939
+ cursor: opts.cursor
940
+ };
941
+ }
942
+ /** A command whose subject is `[node]` or `--member` in `--tree`. */
943
+ function subjectCommand(parent, name, description) {
944
+ return parent.command(`${name} [node]`).description(description).option("--member <member-id>", "Use this member's seat instead of a node id").option("--tree <key-or-id>", "Tree for --member (key or UUID)", DEFAULT_TREE);
945
+ }
946
+ function withPaging(command, idsOnly = true) {
947
+ command.option("--limit <n>", "Most rows to return across pages").option("--cursor <cursor>", "Resume from a next_cursor").option("--page-size <n>", "Rows per request");
948
+ if (idsOnly) command.option("--ids-only", "Return node ids only", false);
949
+ return command;
950
+ }
951
+ /** Every writable tree field as a flag. Clearable fields take `none`. */
952
+ function withTreeFields(command) {
953
+ return command.option("--key <key>", "Tree key, unique per company").option("--name <name>", "Display name").option("--kind <kind>", `Tree kind: ${TREE_KINDS.join(", ")}`).option("--role <role>", `Default-tree role: ${TREE_ROLES.join(", ")}, or none`).option("--leg-names <legs>", "Comma-separated legs: two for binary (default left,right), two or more for matrix").option("--placement-rule <rule>", `Default placement rule: ${TREE_PLACEMENT_RULES.join(", ")}. Binary and matrix trees accept exact, first_empty, left, right or defer; unilevel trees exact, under_enroller or mirror_enrollment`).option("--movement-approval <mode>", `Who approves member-requested moves: ${MOVEMENT_APPROVALS.join(", ")}`).option("--holding-tank-days <n>", "Days a deferred seat may wait in the holding tank (0 turns it off)").option("--max-depth <n>", "Deepest level the tree allows").option("--max-movement-levels <n>", "How many levels a member move may cross (0 = no limit)").option("--movement-window-days <n>", "Days after placement during which a seat may move (0 = no window)").option("--volume-on-move <mode>", `Where volume goes when a seat moves: ${VOLUME_ON_MOVE.join(", ")}, or none; required for binary and matrix`).option("--volume-key <key>", "Volume key the tree ranks by, or none").option("--allow-multiple-positions <bool>", "true lets a member hold several seats (matrix trees)").option("--authority <authority>", `Ownership: ${TREE_AUTHORITIES.join(", ")}. Only authoritative trees take placements`).option("--owning-company-integration-id <id>", "Integration that owns a replica tree, or none").option("--upstream-tree-ref <ref>", "The back office's id for a replica tree, or none").option("--external-integration-market-id <id>", "The back office market a replica tree belongs to, or none").option("--dry-run", "Print the request and send nothing", false).option("--yes", "Confirm without a prompt", false);
954
+ }
955
+ /** `trees create | update <tree> | delete <tree>`. Writes show the request
956
+ * and ask first unless --yes; --dry-run prints it and sends nothing. */
957
+ function registerTreeWrites(trees, ctx) {
958
+ const send = async (api, write, label, opts, verb) => {
959
+ if (opts.dryRun) {
960
+ ctx.output({
961
+ dry_run: true,
962
+ request: write
963
+ });
964
+ return;
965
+ }
966
+ const shown = write.body ?? { params: write.params };
967
+ await confirmChange(`${verb} tree ${label} with ${write.method} ${write.path} ${JSON.stringify(shown)}?`, opts.yes);
968
+ ctx.output(await sendTreeWrite(await api(), write, label));
969
+ };
970
+ const permission = "Needs an admin login with developer.update; a company API token works if its role grants it.";
971
+ withTreeFields(trees.command("create").description(`Add a tree definition. --key, --name, --kind and --authority are required. ${permission}`)).action((opts) => run(async () => {
972
+ await send(() => client(ctx), planCreateTree(opts), String(opts.key), opts, "Create");
973
+ }));
974
+ withTreeFields(trees.command("update <tree>").description(`Change a tree definition by key or UUID; sends only the flags given. An occupied tree can only be renamed. ${permission}`)).option("--lock-version <n>", "Update only if the tree is still at this version (default: the current one)").action((tree, opts) => run(async () => {
975
+ const api = await client(ctx);
976
+ await send(async () => api, await planUpdateTree(api, tree, opts, opts.lockVersion), tree, opts, "Update");
977
+ }));
978
+ trees.command("mirror [tree]").description("Mirror the enrollment tree into the placement tree: seat each enrolled member without a placement seat under their enroller's seat (roots first) and re-parent unpinned seats; pinned seats stay. Idempotent. A real run is queued in the background and printed; --wait follows it to the end. Needs an admin login with members.manage, GENEALOGY_TREE_WRITES, authoritative default trees and the mirror_enrollment rule.").option("--dry-run", "Report at once what would change and write nothing", false).option("--wait", "Follow the run until it finishes; exit non-zero if it fails or halts", false).option("--interval <seconds>", "Seconds between readings with --wait", "2").option("--yes", "Confirm without a prompt", false).action((tree, opts) => run(async () => {
979
+ const api = await client(ctx);
980
+ const label = tree ?? "placement";
981
+ const treeId = await resolveTreeId(api, label);
982
+ if (opts.dryRun) {
983
+ ctx.output(await previewMirror(api, treeId));
984
+ return;
985
+ }
986
+ const intervalMs = (toPositiveInt("--interval", opts.interval) ?? 2) * 1e3;
987
+ await confirmChange(`Mirror the enrollment tree into tree ${label}?`, opts.yes);
988
+ const started = await startMirror(api, treeId);
989
+ if (!opts.wait || !started.mirror_run) {
990
+ ctx.output(started);
991
+ return;
992
+ }
993
+ const finished = await waitForMirrorRun(api, started.mirror_run, {
994
+ intervalMs,
995
+ onProgress: reportMirrorProgress
996
+ });
997
+ ctx.output({
998
+ mirror_run: finished,
999
+ started: started.started
1000
+ });
1001
+ if (finished.status !== "succeeded") throw new GenealogyError(finished.failure_description ?? `Mirror run ${finished.status}`, finished.failure_type ? { type: finished.failure_type } : {});
1002
+ }));
1003
+ const mirrorRuns = trees.command("mirror-runs").description("Read background placement mirror runs");
1004
+ mirrorRuns.command("list").description("List the company's most recent mirror runs, newest first").option("--status <status>", "queued, running, succeeded, failed or halted").option("--limit <n>", "How many runs, 1 to 100 (default 25)").action((opts) => run(async () => {
1005
+ ctx.output(await listMirrorRuns(await client(ctx), {
1006
+ status: opts.status,
1007
+ limit: toPositiveInt("--limit", opts.limit)
1008
+ }));
1009
+ }));
1010
+ mirrorRuns.command("show <id>").description("Read a mirror run's progress").action((id) => run(async () => {
1011
+ ctx.output({ mirror_run: await getMirrorRun(await client(ctx), id) });
1012
+ }));
1013
+ trees.command("delete <tree>").description(`Delete a custom tree by key or UUID. It fails with tree_has_nodes while the tree holds any seat, and the default enrollment and placement trees can't be deleted. ${permission}`).option("--lock-version <n>", "Delete only if the tree is still at this version (default: the current one)").option("--dry-run", "Print the request and send nothing", false).option("--yes", "Confirm without a prompt", false).action((tree, opts) => run(async () => {
1014
+ const api = await client(ctx);
1015
+ await send(async () => api, await planDeleteTree(api, tree, opts.lockVersion), tree, opts, "Delete");
1016
+ }));
1017
+ }
1018
+ function registerGenealogyCommands(parent, ctx) {
1019
+ const subject = async (api, node, opts) => resolveSubject(api, node, opts.member, opts.tree);
1020
+ registerTreeWrites(parent.command("trees").description("List the company's genealogy tree definitions, or create, update or delete one (needs developer.view; writes need developer.update)").action(() => run(async () => ctx.output({ trees: await listTrees(await client(ctx)) }))), ctx);
1021
+ subjectCommand(parent, "show", "Read a node, with child and downline counts").option("--as-of <time>", "Read the recorded placement at this time").action((node, opts) => run(async () => {
1022
+ const api = await client(ctx);
1023
+ ctx.output(await readNode(api, await subject(api, node, opts), opts.asOf));
1024
+ }));
1025
+ subjectCommand(parent, "counts", "Children and downline counts (retained seats, including discarded members)").action((node, opts) => run(async () => {
1026
+ const api = await client(ctx);
1027
+ ctx.output(await readCounts(api, await subject(api, node, opts)));
1028
+ }));
1029
+ for (const [operation, description, asOf] of [
1030
+ [
1031
+ "parent",
1032
+ "Read the parent node (null for a root)",
1033
+ true
1034
+ ],
1035
+ [
1036
+ "root",
1037
+ "Read the root of the node's tree",
1038
+ true
1039
+ ],
1040
+ [
1041
+ "sponsor",
1042
+ "Read the sponsor: the parent in the sponsor-role tree",
1043
+ false
1044
+ ],
1045
+ [
1046
+ "enroller",
1047
+ "Read the enroller: the parent in the enrollment-role tree",
1048
+ false
1049
+ ]
1050
+ ]) {
1051
+ const command = subjectCommand(parent, operation, description);
1052
+ if (asOf) command.option("--as-of <time>", "Read as of this time");
1053
+ command.action((node, opts) => run(async () => {
1054
+ const api = await client(ctx);
1055
+ const id = await subject(api, node, opts);
1056
+ ctx.output(await readRelative(api, id, operation, opts.asOf));
1057
+ }));
1058
+ }
1059
+ for (const [operation, description, extra] of [
1060
+ [
1061
+ "ancestors",
1062
+ "List ancestors, root first",
1063
+ "asOf"
1064
+ ],
1065
+ [
1066
+ "children",
1067
+ "List direct children",
1068
+ null
1069
+ ],
1070
+ [
1071
+ "siblings",
1072
+ "List siblings (excluding the node)",
1073
+ null
1074
+ ],
1075
+ [
1076
+ "frontline",
1077
+ "List direct children in the sponsor-role tree",
1078
+ null
1079
+ ],
1080
+ [
1081
+ "personally-enrolled",
1082
+ "List direct children in the enrollment-role tree",
1083
+ null
1084
+ ],
1085
+ [
1086
+ "descendants",
1087
+ "List descendants to --depth",
1088
+ "depth"
1089
+ ],
1090
+ [
1091
+ "subtree",
1092
+ "List the node and its descendants to --depth",
1093
+ "depth"
1094
+ ],
1095
+ [
1096
+ "level",
1097
+ "List the nodes exactly --depth levels down",
1098
+ "depth"
1099
+ ]
1100
+ ]) {
1101
+ const command = withPaging(subjectCommand(parent, operation, description), operation !== "ancestors");
1102
+ if (extra === "depth") command.option("--depth <n>", `Relative depth, 1-7`);
1103
+ if (extra) command.option("--as-of <time>", "Read as of this time");
1104
+ command.action((node, opts) => run(async () => {
1105
+ const api = await client(ctx);
1106
+ const id = await subject(api, node, opts);
1107
+ ctx.output(await readNodePage(api, id, operation, {
1108
+ ...paging(opts),
1109
+ depth: opts.depth,
1110
+ asOf: opts.asOf,
1111
+ idsOnly: opts.idsOnly
1112
+ }));
1113
+ }));
1114
+ }
1115
+ withPaging(subjectCommand(parent, "grandchildren", "List the nodes two levels down")).option("--as-of <time>", "Read as of this time").action((node, opts) => run(async () => {
1116
+ const api = await client(ctx);
1117
+ const id = await subject(api, node, opts);
1118
+ ctx.output(await readGrandchildren(api, id, {
1119
+ ...paging(opts),
1120
+ asOf: opts.asOf,
1121
+ idsOnly: opts.idsOnly
1122
+ }));
1123
+ }));
1124
+ subjectCommand(parent, "level-counts", "Count seats at each level to --depth").option("--depth <n>", `Relative depth, 1-7`).action((node, opts) => run(async () => {
1125
+ const api = await client(ctx);
1126
+ const id = await subject(api, node, opts);
1127
+ ctx.output(await readLevelCounts(api, id, opts.depth));
1128
+ }));
1129
+ parent.command("relation <node> <other>").description("How <node> relates to <other>: one --kind, or every kind (for within_downline, <other> is the root)").option("--kind <kind>", "ancestor_of, descendant_of, parent_of, child_of, sibling_of, root_of, indirect_of, in_subtree_of or within_downline").action((node, other, opts) => run(async () => {
1130
+ const api = await client(ctx);
1131
+ const id = await resolveSubject(api, node, void 0);
1132
+ ctx.output(await readRelation(api, id, other, opts.kind));
1133
+ }));
1134
+ withPaging(subjectCommand(parent, "placements", "List recorded placements, newest first"), false).action((node, opts) => run(async () => {
1135
+ const api = await client(ctx);
1136
+ const id = await subject(api, node, opts);
1137
+ ctx.output(await readPlacements(api, id, paging(opts)));
1138
+ }));
1139
+ withPaging(parent.command("holding-tank").description("List seats waiting in a tree's holding tank, soonest due first").option("--tree <key-or-id>", "Tree (key or UUID)", DEFAULT_TREE), false).action((opts) => run(async () => {
1140
+ const api = await client(ctx);
1141
+ const treeId = await resolveTreeId(api, opts.tree);
1142
+ ctx.output(await readHoldingTank(api, treeId, paging(opts)));
1143
+ }));
1144
+ const legLimit = (command) => command.option("--limit <n>", `Most direct children to count, one request each (default 200); more is an error`);
1145
+ const legOptions = (opts) => ({ maxChildren: toPositiveInt("--limit", opts.limit) });
1146
+ legLimit(subjectCommand(parent, "legs", "Rank the node's legs by headcount (child plus its downline), largest first")).action((node, opts) => run(async () => {
1147
+ const api = await client(ctx);
1148
+ ctx.output(await readLegs(api, await subject(api, node, opts), legOptions(opts)));
1149
+ }));
1150
+ for (const which of ["strong", "weak"]) legLimit(subjectCommand(parent, `${which}-leg`, `The ${which === "strong" ? "largest" : "smallest"} leg by headcount`)).action((node, opts) => run(async () => {
1151
+ const api = await client(ctx);
1152
+ const report = await readLegs(api, await subject(api, node, opts), legOptions(opts));
1153
+ ctx.output(pickLeg(report, which));
1154
+ }));
1155
+ subjectCommand(parent, "tree", "Print the downline as an indented text tree").option("--depth <n>", `Levels to show, 1-7`, "3").action((node, opts) => run(async () => {
1156
+ const api = await client(ctx);
1157
+ const read = await readTree(api, await subject(api, node, opts), opts.depth);
1158
+ process.stdout.write(renderTree(read.nodes, read.depthTruncated, read.depth));
1159
+ }));
1160
+ subjectCommand(parent, "place", "Place a held seat, or with --member give that member a new seat in --tree (changes the live tree)").option("--parent <node>", "Parent seat, or where a rule's search starts").option("--root", "Place as a root seat", false).option("--leg <leg>", "Leg under the parent, e.g. left or right; with neither --leg nor --rule the tree's default rule applies").option("--rule <rule>", "Placement rule, e.g. first_empty, left, right, defer").option("--position <n>", "Seat position (matrix trees with multiple positions)").option("--reason <text>", "Reason recorded on the placement").option("--yes", "Confirm without a prompt", false).action((node, opts) => run(async () => {
1161
+ if (opts.parent && opts.root) throw new GenealogyError("Pass --parent or --root, not both.");
1162
+ if (node && opts.member) throw new GenealogyError("Pass a held node id or --member, not both.");
1163
+ const api = await client(ctx);
1164
+ const input = {
1165
+ parent: opts.root ? null : opts.parent,
1166
+ leg: opts.leg,
1167
+ rule: opts.rule,
1168
+ reason: opts.reason
1169
+ };
1170
+ if (opts.member) {
1171
+ const treeId = await resolveTreeId(api, opts.tree);
1172
+ await confirmChange(`Place member ${opts.member} in tree ${opts.tree}?`, opts.yes);
1173
+ ctx.output(await placeMember(api, treeId, opts.member, {
1174
+ ...input,
1175
+ position: opts.position === void 0 ? void 0 : Number(opts.position)
1176
+ }));
1177
+ return;
1178
+ }
1179
+ if (opts.position !== void 0) throw new GenealogyError("--position applies only with --member; a held seat keeps its stored position.");
1180
+ const id = await resolveSubject(api, node, void 0);
1181
+ await confirmChange(`Place held seat ${id}?`, opts.yes);
1182
+ ctx.output(await placeHeldSeat(api, id, input));
1183
+ }));
1184
+ subjectCommand(parent, "move", "Move a node under a new parent (changes the live tree); --validate previews only").option("--to <node>", "New parent node").option("--to-member <member-id>", "New parent: this member's seat in --tree").option("--to-root", "Make the node a root", false).option("--leg <leg>", "Leg under the new parent (binary and matrix trees)").option("--reason <text>", "Reason recorded on the move").option("--validate", "Preview the move without changing anything", false).option("--dry-run", "Same as --validate", false).option("--idempotency-key <key>", "Make a retried request safe to repeat").option("--yes", "Confirm without a prompt", false).action((node, opts) => run(async () => {
1185
+ if ([
1186
+ opts.to,
1187
+ opts.toMember,
1188
+ opts.toRoot || void 0
1189
+ ].filter((d) => d !== void 0).length !== 1) throw new GenealogyError("Pass exactly one of --to <node>, --to-member <member-id> or --to-root.");
1190
+ const api = await client(ctx);
1191
+ const id = await subject(api, node, opts);
1192
+ const parentId = opts.toRoot ? null : await resolveSubject(api, opts.to, opts.toMember, opts.tree);
1193
+ const input = {
1194
+ parent: parentId,
1195
+ leg: opts.leg,
1196
+ reason: opts.reason
1197
+ };
1198
+ if (opts.validate || opts.dryRun) {
1199
+ ctx.output(await validateMove(api, id, input));
1200
+ return;
1201
+ }
1202
+ await confirmChange(`Move ${id} ${parentId ? `under ${parentId}` : "to a root"}?`, opts.yes);
1203
+ ctx.output(await requestMove(api, id, input, opts.idempotencyKey));
1204
+ }));
1205
+ const requests = parent.command("move-requests").description("List, read, approve or reject move requests");
1206
+ withPaging(requests.command("list").description("List a tree's move requests").option("--tree <key-or-id>", "Tree (key or UUID)", DEFAULT_TREE).option("--status <status>", "pending, approved, rejected, withdrawn, superseded or expired"), false).action((opts) => run(async () => {
1207
+ const api = await client(ctx);
1208
+ const treeId = await resolveTreeId(api, opts.tree);
1209
+ ctx.output(await listMoveRequests(api, treeId, opts.status, paging(opts)));
1210
+ }));
1211
+ requests.command("show <id>").description("Read a move request").action((id) => run(async () => {
1212
+ ctx.output({ move_request: await getMoveRequest(await client(ctx), id) });
1213
+ }));
1214
+ requests.command("approve <id>").description("Approve a move request (changes the live tree)").option("--reason <text>", "Reason recorded on the decision").option("--yes", "Confirm without a prompt", false).action((id, opts) => run(async () => {
1215
+ await confirmChange(`Approve move request ${id}?`, opts.yes);
1216
+ ctx.output(await approveMoveRequest(await client(ctx), id, opts.reason));
1217
+ }));
1218
+ requests.command("reject <id>").description("Reject a move request with a reason").requiredOption("--reason <text>", "Why the request is rejected").option("--yes", "Confirm without a prompt", false).action((id, opts) => run(async () => {
1219
+ await confirmChange(`Reject move request ${id}?`, opts.yes);
1220
+ ctx.output(await rejectMoveRequest(await client(ctx), id, opts.reason));
1221
+ }));
1222
+ const jobs = parent.command("move-jobs").description("Read deferred move jobs");
1223
+ jobs.command("show <id>").description("Read a move job's progress").action((id) => run(async () => {
1224
+ ctx.output({ move_job: await getMoveJob(await client(ctx), id) });
1225
+ }));
1226
+ withPaging(jobs.command("list").description("List a tree's move jobs").option("--tree <key-or-id>", "Tree (key or UUID)", DEFAULT_TREE).option("--status <status>", "pending, running, applied, failed or superseded"), false).action((opts) => run(async () => {
1227
+ const api = await client(ctx);
1228
+ const treeId = await resolveTreeId(api, opts.tree);
1229
+ ctx.output(await listMoveJobs(api, treeId, opts.status, paging(opts)));
1230
+ }));
1231
+ }
1232
+ const plugin = {
1233
+ name: "@fluid-app/fluid-cli-genealogy",
1234
+ version: "0.1.0",
1235
+ register(ctx) {
1236
+ ctx.program.addCommand(createDomainCommand("genealogy", "Read genealogy trees, rank legs, and place or move members", registerGenealogyCommands));
1237
+ }
1238
+ };
1239
+ //#endregion
1240
+ export { confirmChange, plugin as default, registerGenealogyCommands };
1241
+
1242
+ //# sourceMappingURL=index.mjs.map