@indigoai-us/hq-cli 5.94.1 → 5.94.2

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.
@@ -0,0 +1,664 @@
1
+ import chalk from "chalk";
2
+ import * as readline from "node:readline";
3
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
4
+ import { vaultApiFetch, getEntityUid } from "../utils/vault-api.js";
5
+ import { listActiveMembers } from "./members.js";
6
+ /**
7
+ * `hq company transfer` — company ownership handover (client-service-pack
8
+ * US-010). The server adds ZERO new API routes (vault-api-hq-prod sits at the
9
+ * 600-route quota), so the CLI half multiplexes onto two EXISTING routes:
10
+ *
11
+ * - WRITES ride `POST /membership/role` with a body discriminator
12
+ * `action: "transfer-initiate" | "transfer-accept" | "transfer-decline" |
13
+ * "transfer-cancel"`. The ordinary role payload has never carried an
14
+ * `action` field, so the shapes cannot collide.
15
+ * - The READ rides `GET /membership/company/{companyUid}?view=transfers`;
16
+ * without that exact `view` value the route returns the member roster as
17
+ * before.
18
+ *
19
+ * TWO-PARTY BY CONSTRUCTION. `initiate` NOMINATES; it does not transfer
20
+ * anything. Ownership moves only when the nominee runs `accept`. The four write
21
+ * transitions (initiate / accept / decline / cancel) all multiplex through one
22
+ * POST with an explicit `action`, matching the server's route shape.
23
+ *
24
+ * ABSENT-FIELD DISCIPLINE (policy `hq-absent-field-never-means-constraining-value`).
25
+ * This flow spans two repos that deploy in EITHER order, so absence is load
26
+ * bearing in both directions and is handled in both directions here:
27
+ *
28
+ * - REQUESTS: a flag the operator did not pass is OMITTED from the body — it
29
+ * is never defaulted to a value on the wire. `--initiator-role` unset sends
30
+ * no `initiatorRole` at all, so the server applies its own reversible
31
+ * "keep them as admin" default; the CLI can therefore never turn silence
32
+ * into `remove`. Same for `--transfer-id` (absent = "the pending one").
33
+ * - RESPONSES: fields are read with presence and value SEPARATELY
34
+ * ({@link readWireField}), because an OLDER server omits fields a newer CLI
35
+ * knows about. An absent `effects.initiator` renders as "not reported",
36
+ * never as "removed" — the CLI must not narrate a destructive outcome it
37
+ * was never actually told about.
38
+ *
39
+ * Conventions mirror `company.ts` / `files.ts`: ensureCognitoToken() →
40
+ * getEntityUid() → vaultApiFetch() → error-check → chalk output, with the
41
+ * confirmation prompt behind an injectable seam so tests can drive it.
42
+ */
43
+ /** The four write transitions the server accepts. */
44
+ export const TRANSFER_ACTIONS = [
45
+ "initiate",
46
+ "accept",
47
+ "decline",
48
+ "cancel",
49
+ ];
50
+ /**
51
+ * Legal `--initiator-role` values. `owner` is deliberately absent: a handover
52
+ * where the outgoing owner keeps `owner` is not a handover. `remove` is the
53
+ * ONLY value that takes them off the company, and it can only ever arrive from
54
+ * an explicit flag.
55
+ */
56
+ export const INITIATOR_ROLE_VALUES = [
57
+ "admin",
58
+ "member",
59
+ "guest",
60
+ "remove",
61
+ ];
62
+ /**
63
+ * What the SERVER falls back to when `initiatorRole` is absent. Mirrored here
64
+ * only so the confirmation preview can tell the operator what will happen; the
65
+ * CLI never puts this value on the wire, because sending it would defeat the
66
+ * point of leaving the field absent.
67
+ */
68
+ export const DEFAULT_INITIATOR_ROLE = "admin";
69
+ /**
70
+ * Read a field off a server response reporting presence and value SEPARATELY.
71
+ *
72
+ * The CLI-side twin of hq-pro's `readField`. `body.x ?? fallback` is wrong here
73
+ * because it makes an OLDER server's omitted field indistinguishable from a
74
+ * newer server explicitly sending `null`/`false`/`""` — and every caller below
75
+ * needs to say "this server didn't tell me" rather than invent an answer.
76
+ */
77
+ export function readWireField(body, key) {
78
+ if (!body || typeof body !== "object") {
79
+ return { present: false, value: undefined };
80
+ }
81
+ if (!Object.prototype.hasOwnProperty.call(body, key)) {
82
+ return { present: false, value: undefined };
83
+ }
84
+ const value = body[key];
85
+ if (value === undefined)
86
+ return { present: false, value: undefined };
87
+ return { present: true, value: value };
88
+ }
89
+ function asRecord(value) {
90
+ return value && typeof value === "object" && !Array.isArray(value)
91
+ ? value
92
+ : undefined;
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // Flag parsing
96
+ // ---------------------------------------------------------------------------
97
+ /**
98
+ * Parse `--initiator-role`.
99
+ *
100
+ * Returns `undefined` when the flag was NOT passed, and that `undefined` means
101
+ * "omit the field entirely" — never "remove", and never a substituted default.
102
+ * A present-but-invalid value throws instead of falling back, so a typo can
103
+ * never be silently reinterpreted as a different disposition.
104
+ */
105
+ export function parseInitiatorRole(raw) {
106
+ if (raw === undefined)
107
+ return undefined;
108
+ const v = raw.trim().toLowerCase();
109
+ if (v === "owner") {
110
+ throw new Error("--initiator-role cannot be 'owner' — a transfer must downgrade the outgoing owner");
111
+ }
112
+ if (INITIATOR_ROLE_VALUES.includes(v)) {
113
+ return v;
114
+ }
115
+ throw new Error(`--initiator-role must be one of ${INITIATOR_ROLE_VALUES.join(", ")} (got '${raw}')`);
116
+ }
117
+ /**
118
+ * The wire prefix that turns `POST /membership/role` into a transfer request.
119
+ * Mirrors the server's `TRANSFER_ACTION_PREFIX` in `_ownership-transfer.ts`:
120
+ * a body whose `action` starts with `transfer-` is a transfer; anything else
121
+ * (including no `action` at all) is an ordinary role change.
122
+ */
123
+ export const TRANSFER_ACTION_PREFIX = "transfer-";
124
+ /**
125
+ * The query discriminator on `GET /membership/company/{companyUid}` that
126
+ * selects the transfer surface instead of the member roster. Mirrors the
127
+ * server's `TRANSFER_HISTORY_VIEW` (exact match on the server side).
128
+ */
129
+ export const TRANSFER_HISTORY_VIEW = "transfers";
130
+ /**
131
+ * Build the `POST /membership/role` transfer body. The CLI-side `action`
132
+ * ("initiate" | …) is prefixed to the wire discriminator ("transfer-initiate"
133
+ * | …) HERE, so no call site can accidentally post an unprefixed action that
134
+ * the server would route to the ordinary role-change handler.
135
+ *
136
+ * Every optional field is included ONLY when the caller actually supplied it.
137
+ * This is the single choke point for the request half of the absent-field rule:
138
+ * if a value is not here, the server sees no key at all and applies its own
139
+ * safe default, which keeps the CLI correct against a server that predates any
140
+ * of these fields.
141
+ */
142
+ export function buildTransferBody(params) {
143
+ const body = {
144
+ companyUid: params.companyUid,
145
+ action: `${TRANSFER_ACTION_PREFIX}${params.action}`,
146
+ };
147
+ if (params.targetPersonUid !== undefined) {
148
+ body.targetPersonUid = params.targetPersonUid;
149
+ }
150
+ if (params.initiatorRole !== undefined) {
151
+ body.initiatorRole = params.initiatorRole;
152
+ }
153
+ if (params.transferId !== undefined)
154
+ body.transferId = params.transferId;
155
+ if (params.reason !== undefined)
156
+ body.reason = params.reason;
157
+ return body;
158
+ }
159
+ // ---------------------------------------------------------------------------
160
+ // Human-readable outcome descriptions
161
+ // ---------------------------------------------------------------------------
162
+ /**
163
+ * Describe what the confirmation prompt should say happens to the OUTGOING
164
+ * owner. Absence resolves to the reversible, non-destructive sentence.
165
+ */
166
+ export function describeInitiatorOutcome(role) {
167
+ if (role === undefined) {
168
+ return `stay on the company as ${DEFAULT_INITIATOR_ROLE} (the default — NOT removed)`;
169
+ }
170
+ if (role === "remove")
171
+ return "be REMOVED from the company entirely";
172
+ return `be downgraded to ${role}`;
173
+ }
174
+ /**
175
+ * Render the `effects` block of an accepted transfer.
176
+ *
177
+ * Reads every field for PRESENCE first. An older server that does not send
178
+ * `effects`, or omits a field inside it, gets an honest "not reported" line —
179
+ * inventing "removed" or "unchanged" here would be the CLI asserting a
180
+ * destructive outcome it has no evidence for.
181
+ */
182
+ export function describeEffects(rawEffects) {
183
+ const effects = asRecord(rawEffects);
184
+ if (!effects) {
185
+ return [
186
+ "This server did not report the resulting changes. Run " +
187
+ "`hq company transfer status` to confirm what actually moved.",
188
+ ];
189
+ }
190
+ const lines = [];
191
+ const targetRole = readWireField(effects, "targetRole");
192
+ lines.push(targetRole.present
193
+ ? `New owner role: ${String(targetRole.value)}`
194
+ : "New owner role: not reported by this server");
195
+ const initiator = readWireField(effects, "initiator");
196
+ const initiatorRole = readWireField(effects, "initiatorRole");
197
+ if (!initiator.present) {
198
+ lines.push("Previous owner: not reported by this server (no removal implied)");
199
+ }
200
+ else if (initiator.value === "removed") {
201
+ lines.push("Previous owner: REMOVED from the company");
202
+ }
203
+ else if (initiator.value === "downgraded") {
204
+ lines.push(`Previous owner: downgraded to ${initiatorRole.present ? String(initiatorRole.value) : "a non-owner role"}`);
205
+ }
206
+ else if (initiator.value === "already-at-role") {
207
+ lines.push(`Previous owner: already at ${initiatorRole.present ? String(initiatorRole.value) : "the target role"}`);
208
+ }
209
+ else {
210
+ lines.push(`Previous owner: ${String(initiator.value)}`);
211
+ }
212
+ const custody = readWireField(effects, "custody");
213
+ lines.push(custody.present
214
+ ? `Vault custody: ${String(custody.value)}`
215
+ : "Vault custody: not reported by this server");
216
+ const billingFollowed = readWireField(effects, "billingOwnerFollowed");
217
+ if (billingFollowed.present) {
218
+ lines.push(`Billing owner: ${billingFollowed.value === true ? "follows the new owner" : "unchanged"}`);
219
+ }
220
+ return lines;
221
+ }
222
+ // ---------------------------------------------------------------------------
223
+ // Error rendering
224
+ // ---------------------------------------------------------------------------
225
+ /** Server failure codes → an operator-readable sentence. */
226
+ const FAILURE_MESSAGES = {
227
+ NOT_AN_OWNER: "You must be an active owner of this company.",
228
+ TARGET_NOT_MEMBER: "That person is not a member of this company.",
229
+ TARGET_NOT_ACTIVE: "That person's membership is not active yet.",
230
+ TARGET_IS_SELF: "You cannot transfer the company to yourself.",
231
+ AGENT_ROLE_CAP: "A fleet agent cannot be made owner of a company.",
232
+ NO_PENDING_TRANSFER: "There is no pending ownership transfer for this company.",
233
+ TRANSFER_ID_MISMATCH: "That transfer id does not match the currently pending transfer.",
234
+ NOT_THE_NOMINEE: "Only the nominated person can accept or decline.",
235
+ NOT_THE_INITIATOR: "Only the owner who initiated it can cancel.",
236
+ OWNERSHIP_TRANSFER_CONFLICT: "The transfer changed underneath this request — re-check status and retry.",
237
+ };
238
+ function renderApiError(status, body) {
239
+ const code = readWireField(body, "code");
240
+ if (code.present && FAILURE_MESSAGES[String(code.value)]) {
241
+ return FAILURE_MESSAGES[String(code.value)];
242
+ }
243
+ const error = readWireField(body, "error");
244
+ if (error.present && typeof error.value === "string")
245
+ return error.value;
246
+ return `Request failed (HTTP ${status})`;
247
+ }
248
+ /**
249
+ * The real prompt. Refuses (rather than assuming yes) when stdin is not a TTY,
250
+ * so a transfer can never be initiated or accepted by a pipeline that simply
251
+ * had nobody to ask. `--yes` is the only non-interactive path, and it is an
252
+ * explicit operator act.
253
+ *
254
+ * Exported ONLY so the non-TTY refusal — the one confirmation path the
255
+ * injectable seam hides from the command tests — can be covered directly.
256
+ */
257
+ export function realConfirm(message) {
258
+ if (!process.stdin.isTTY) {
259
+ console.error(chalk.red("Refusing to continue without confirmation: stdin is not a terminal. " +
260
+ "Re-run interactively, or pass --yes if you have already verified the change."));
261
+ return Promise.resolve(false);
262
+ }
263
+ const rl = readline.createInterface({
264
+ input: process.stdin,
265
+ output: process.stdout,
266
+ });
267
+ return new Promise((resolve) => {
268
+ rl.question(`${message} [y/N] `, (answer) => {
269
+ rl.close();
270
+ resolve(/^y(es)?$/i.test(answer.trim()));
271
+ });
272
+ });
273
+ }
274
+ // ---------------------------------------------------------------------------
275
+ // Target resolution
276
+ // ---------------------------------------------------------------------------
277
+ const PERSON_UID_PATTERN = /^prs_[A-Za-z0-9_-]+$/;
278
+ const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/i;
279
+ /**
280
+ * Resolve `--to` to a `personUid`. A uid is used as-is; anything else is
281
+ * matched (case-insensitively) against the company's ACTIVE roster by email,
282
+ * slug, or name, because the server requires a uid and only accepts a target
283
+ * who is already an active member.
284
+ */
285
+ export function matchMemberTarget(members, target) {
286
+ const needle = target.trim().toLowerCase();
287
+ return members.find((m) => m.personEmail?.toLowerCase() === needle ||
288
+ m.personSlug?.toLowerCase() === needle ||
289
+ m.personName?.toLowerCase() === needle ||
290
+ m.personUid?.toLowerCase() === needle);
291
+ }
292
+ export async function resolveTargetPersonUid(token, companyUid, target) {
293
+ const trimmed = target.trim();
294
+ if (PERSON_UID_PATTERN.test(trimmed) || AGENT_UID_PATTERN.test(trimmed)) {
295
+ return { personUid: trimmed, label: trimmed };
296
+ }
297
+ const members = await listActiveMembers(token, companyUid);
298
+ const match = matchMemberTarget(members, trimmed);
299
+ if (!match) {
300
+ throw new Error(`No active member of this company matches '${target}'. ` +
301
+ "Pass a prs_… uid, or check `hq members list`.");
302
+ }
303
+ const label = match.personEmail
304
+ ? `${match.personEmail} (${match.personUid})`
305
+ : match.personUid;
306
+ return { personUid: match.personUid, label };
307
+ }
308
+ /**
309
+ * Post one transition. Returns the parsed success body, or throws with an
310
+ * operator-readable message.
311
+ */
312
+ async function postTransfer(token, body) {
313
+ const res = await vaultApiFetch({
314
+ token,
315
+ path: "/membership/role",
316
+ method: "POST",
317
+ body,
318
+ });
319
+ const parsed = (await res.json().catch(() => ({})));
320
+ if (!res.ok)
321
+ throw new Error(renderApiError(res.status, parsed));
322
+ return parsed;
323
+ }
324
+ /** Look up the currently pending nomination, for the accept/decline preview. */
325
+ async function fetchCurrent(token, companyUid) {
326
+ const res = await vaultApiFetch({
327
+ token,
328
+ path: `/membership/company/${encodeURIComponent(companyUid)}`,
329
+ // `view=transfers` (exact match) is the discriminator that selects the
330
+ // transfer surface; without it this route returns the member roster.
331
+ query: { view: TRANSFER_HISTORY_VIEW },
332
+ });
333
+ if (!res.ok)
334
+ return undefined;
335
+ const body = (await res.json().catch(() => ({})));
336
+ const pending = readWireField(body, "pending");
337
+ if (pending.present)
338
+ return asRecord(pending.value);
339
+ return asRecord(readWireField(body, "transfer").value);
340
+ }
341
+ /**
342
+ * `hq company transfer initiate|accept|decline|cancel`.
343
+ *
344
+ * `initiate` and `accept` are the two transitions that move (or commit to
345
+ * moving) ownership, so both stop for an explicit confirmation that spells out
346
+ * who becomes owner and what happens to the outgoing owner. `decline` and
347
+ * `cancel` only ever ABORT a nomination, so they do not prompt.
348
+ */
349
+ export async function runCompanyTransfer(params, deps = {}) {
350
+ const confirm = deps.confirm ?? realConfirm;
351
+ // `--yes` is an explicit operator act and the ONLY way past the prompt. The
352
+ // change summary is printed either way, so even a scripted run leaves a
353
+ // record of exactly what was about to happen. Absence of `--yes` never
354
+ // resolves to "assume yes".
355
+ const gate = async (message) => params.yes === true ? true : confirm(message);
356
+ // Parsed BEFORE any network call so an invalid value never reaches the wire.
357
+ const initiatorRole = parseInitiatorRole(params.initiatorRoleRaw);
358
+ const token = await ensureCognitoToken();
359
+ const companyUid = await getEntityUid(token, {
360
+ companySlug: params.companySlug,
361
+ });
362
+ if (params.action === "initiate") {
363
+ if (!params.to) {
364
+ throw new Error("--to <email|prs_uid> is required for `initiate`.");
365
+ }
366
+ const target = await resolveTargetPersonUid(token, companyUid, params.to);
367
+ console.log(chalk.bold("\nOwnership transfer — nomination"));
368
+ console.log(` Company: ${params.companySlug} (${companyUid})`);
369
+ console.log(` Becomes owner: ${target.label}`);
370
+ console.log(` You will: ${describeInitiatorOutcome(initiatorRole)}`);
371
+ console.log(chalk.yellow("\n This NOMINATES them. Ownership moves only once they accept.\n" +
372
+ " On acceptance, owner role, billing authority, and vault custody all move."));
373
+ const ok = await gate(`\nNominate ${target.label} as owner of ${params.companySlug}?`);
374
+ if (!ok) {
375
+ console.log(chalk.yellow("Aborted — nothing was changed."));
376
+ return;
377
+ }
378
+ const result = await postTransfer(token, buildTransferBody({
379
+ companyUid,
380
+ action: "initiate",
381
+ targetPersonUid: target.personUid,
382
+ ...(initiatorRole !== undefined ? { initiatorRole } : {}),
383
+ ...(params.reason !== undefined ? { reason: params.reason } : {}),
384
+ }));
385
+ const transfer = asRecord(readWireField(result, "transfer").value);
386
+ const id = readWireField(transfer, "transferId");
387
+ console.log(chalk.green(`\nNominated ${target.label} as owner of ${params.companySlug}.`));
388
+ if (id.present)
389
+ console.log(` Transfer id: ${String(id.value)}`);
390
+ console.log(" They must run `hq company transfer accept --company " +
391
+ `${params.companySlug}\` to complete it.`);
392
+ return;
393
+ }
394
+ if (params.action === "accept") {
395
+ const current = await fetchCurrent(token, companyUid);
396
+ const from = readWireField(current, "fromPersonUid");
397
+ // The disposition the outgoing owner PROPOSED. Absent (older server, or no
398
+ // readable pending row) must not be narrated as a removal.
399
+ const proposed = asRecord(readWireField(current, "proposedInitiatorDisposition").value);
400
+ const proposedKind = readWireField(proposed, "kind");
401
+ const proposedRole = readWireField(proposed, "role");
402
+ let outgoing;
403
+ if (initiatorRole !== undefined) {
404
+ outgoing = `${describeInitiatorOutcome(initiatorRole)} (your override)`;
405
+ }
406
+ else if (!proposedKind.present) {
407
+ outgoing =
408
+ `stay on as ${DEFAULT_INITIATOR_ROLE} unless they proposed otherwise ` +
409
+ "(this server did not report the proposal)";
410
+ }
411
+ else if (proposedKind.value === "remove") {
412
+ outgoing = "be REMOVED from the company entirely (as they proposed)";
413
+ }
414
+ else {
415
+ outgoing = `be downgraded to ${proposedRole.present ? String(proposedRole.value) : DEFAULT_INITIATOR_ROLE} (as they proposed)`;
416
+ }
417
+ console.log(chalk.bold("\nOwnership transfer — accept"));
418
+ console.log(` Company: ${params.companySlug} (${companyUid})`);
419
+ console.log(` You become: owner`);
420
+ console.log(` Current owner: ${from.present ? String(from.value) : "not reported by this server"}`);
421
+ console.log(` They will: ${outgoing}`);
422
+ console.log(chalk.yellow("\n This is the step that actually moves ownership: owner role,\n" +
423
+ " billing authority, and vault custody. It is hard to reverse —\n" +
424
+ " undoing it needs the NEW owner to transfer back."));
425
+ const ok = await gate(`\nAccept ownership of ${params.companySlug}?`);
426
+ if (!ok) {
427
+ console.log(chalk.yellow("Aborted — nothing was changed. The nomination is still pending."));
428
+ return;
429
+ }
430
+ const result = await postTransfer(token, buildTransferBody({
431
+ companyUid,
432
+ action: "accept",
433
+ ...(initiatorRole !== undefined ? { initiatorRole } : {}),
434
+ ...(params.transferId !== undefined
435
+ ? { transferId: params.transferId }
436
+ : {}),
437
+ ...(params.reason !== undefined ? { reason: params.reason } : {}),
438
+ }));
439
+ console.log(chalk.green(`\nYou are now the owner of ${params.companySlug}.`));
440
+ for (const line of describeEffects(readWireField(result, "effects").value)) {
441
+ console.log(` ${line}`);
442
+ }
443
+ return;
444
+ }
445
+ // decline / cancel — both only ABORT a nomination, so neither prompts.
446
+ const result = await postTransfer(token, buildTransferBody({
447
+ companyUid,
448
+ action: params.action,
449
+ ...(params.transferId !== undefined
450
+ ? { transferId: params.transferId }
451
+ : {}),
452
+ ...(params.reason !== undefined ? { reason: params.reason } : {}),
453
+ }));
454
+ const transfer = asRecord(readWireField(result, "transfer").value);
455
+ const status = readWireField(transfer, "status");
456
+ console.log(chalk.green(params.action === "decline"
457
+ ? `Declined the ownership transfer for ${params.companySlug}.`
458
+ : `Cancelled the ownership transfer for ${params.companySlug}.`));
459
+ if (status.present)
460
+ console.log(` Status: ${String(status.value)}`);
461
+ }
462
+ /** `hq company transfer status` — the pending nomination plus the audit trail. */
463
+ export async function runCompanyTransferStatus(params) {
464
+ const token = await ensureCognitoToken();
465
+ const companyUid = await getEntityUid(token, {
466
+ companySlug: params.companySlug,
467
+ });
468
+ const query = { view: TRANSFER_HISTORY_VIEW };
469
+ if (params.limit !== undefined)
470
+ query.limit = params.limit;
471
+ if (params.cursor !== undefined)
472
+ query.cursor = params.cursor;
473
+ const res = await vaultApiFetch({
474
+ token,
475
+ path: `/membership/company/${encodeURIComponent(companyUid)}`,
476
+ query,
477
+ });
478
+ const body = (await res.json().catch(() => ({})));
479
+ if (!res.ok)
480
+ throw new Error(renderApiError(res.status, body));
481
+ if (params.json) {
482
+ console.log(JSON.stringify(body, null, 2));
483
+ return;
484
+ }
485
+ // `transfer` is sent as an explicit null when nothing has ever happened, so
486
+ // ABSENCE means the server is older and has no transfer surface at all —
487
+ // a genuinely different answer from "no transfer".
488
+ const transferRead = readWireField(body, "transfer");
489
+ if (!transferRead.present) {
490
+ console.log(chalk.yellow("This server did not report transfer state — it may predate company " +
491
+ "ownership transfer."));
492
+ return;
493
+ }
494
+ const current = asRecord(transferRead.value);
495
+ if (!current) {
496
+ console.log(`No ownership transfer has been recorded for ${params.companySlug}.`);
497
+ }
498
+ else {
499
+ const status = readWireField(current, "status");
500
+ const id = readWireField(current, "transferId");
501
+ const from = readWireField(current, "fromPersonUid");
502
+ const to = readWireField(current, "toPersonUid");
503
+ console.log(chalk.bold(`\nOwnership transfer — ${params.companySlug}`));
504
+ console.log(` Status: ${status.present ? String(status.value) : "unknown"}`);
505
+ if (id.present)
506
+ console.log(` Transfer id: ${String(id.value)}`);
507
+ if (from.present)
508
+ console.log(` From: ${String(from.value)}`);
509
+ if (to.present)
510
+ console.log(` To: ${String(to.value)}`);
511
+ if (status.present && status.value === "pending") {
512
+ console.log(chalk.yellow(" Pending — ownership has NOT moved yet; the nominee must accept."));
513
+ }
514
+ }
515
+ const audit = readWireField(body, "audit");
516
+ const rows = Array.isArray(audit.value) ? audit.value : [];
517
+ if (rows.length > 0) {
518
+ console.log(chalk.bold("\nHistory"));
519
+ for (const raw of rows) {
520
+ const row = asRecord(raw);
521
+ const event = readWireField(row, "event");
522
+ const ts = readWireField(row, "timestamp");
523
+ const actor = readWireField(row, "actorPersonUid");
524
+ console.log(` ${ts.present ? String(ts.value) : "?"} ` +
525
+ `${event.present ? String(event.value) : "?"} ` +
526
+ `by ${actor.present ? String(actor.value) : "?"}`);
527
+ }
528
+ }
529
+ const nextCursor = readWireField(body, "nextCursor");
530
+ if (nextCursor.present) {
531
+ console.log(`\n More history: --cursor ${String(nextCursor.value)}`);
532
+ }
533
+ }
534
+ // ---------------------------------------------------------------------------
535
+ // Commander wiring
536
+ // ---------------------------------------------------------------------------
537
+ function fail(err) {
538
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
539
+ process.exit(1);
540
+ }
541
+ /** Resolve `--company` from the transfer subcommand or the parent `company`. */
542
+ function requireCompanySlug(local, parent) {
543
+ const slug = local ?? parent;
544
+ if (!slug)
545
+ throw new Error("--company <slug> is required.");
546
+ return slug;
547
+ }
548
+ export function registerCompanyTransferCommand(company, deps = {}) {
549
+ const transfer = company
550
+ .command("transfer")
551
+ .description("Transfer company ownership. Two-party: `initiate` nominates, and " +
552
+ "ownership moves only when the nominee runs `accept`.")
553
+ .option("--company <slug>", "Company slug (resolves to companyUid)");
554
+ const companyOf = (opts) => requireCompanySlug(opts.company ?? transfer.opts().company, company.opts().company);
555
+ transfer
556
+ .command("initiate")
557
+ .description("Nominate an active member as the new owner. Does NOT transfer ownership " +
558
+ "— the nominee must accept. Prompts for confirmation unless --yes.")
559
+ .requiredOption("--to <email|prs_uid>", "The member to nominate as owner")
560
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
561
+ .option("--initiator-role <admin|member|guest|remove>", "What happens to you after the handover. Omit to keep the safe default " +
562
+ "(stay on as admin); only an explicit 'remove' takes you off.")
563
+ .option("--reason <text>", "Note recorded on the audit trail")
564
+ .option("-y, --yes", "Skip the confirmation prompt (for scripts)")
565
+ .action(async (opts) => {
566
+ try {
567
+ await runCompanyTransfer({
568
+ companySlug: companyOf(opts),
569
+ action: "initiate",
570
+ to: opts.to,
571
+ initiatorRoleRaw: opts.initiatorRole,
572
+ reason: opts.reason,
573
+ yes: opts.yes === true,
574
+ }, deps);
575
+ }
576
+ catch (err) {
577
+ fail(err);
578
+ }
579
+ });
580
+ transfer
581
+ .command("accept")
582
+ .description("Accept a pending nomination and become the owner. This is the step that " +
583
+ "actually moves ownership. Prompts for confirmation unless --yes.")
584
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
585
+ .option("--transfer-id <id>", "Target a specific transfer. Omit to accept the currently pending one.")
586
+ .option("--initiator-role <admin|member|guest|remove>", "Override what happens to the outgoing owner. Omit to confirm what they " +
587
+ "proposed.")
588
+ .option("--reason <text>", "Note recorded on the audit trail")
589
+ .option("-y, --yes", "Skip the confirmation prompt (for scripts)")
590
+ .action(async (opts) => {
591
+ try {
592
+ await runCompanyTransfer({
593
+ companySlug: companyOf(opts),
594
+ action: "accept",
595
+ initiatorRoleRaw: opts.initiatorRole,
596
+ transferId: opts.transferId,
597
+ reason: opts.reason,
598
+ yes: opts.yes === true,
599
+ }, deps);
600
+ }
601
+ catch (err) {
602
+ fail(err);
603
+ }
604
+ });
605
+ transfer
606
+ .command("decline")
607
+ .description("Decline a pending nomination (nominee only).")
608
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
609
+ .option("--transfer-id <id>", "Target a specific transfer. Omit to decline the currently pending one.")
610
+ .option("--reason <text>", "Note recorded on the audit trail")
611
+ .action(async (opts) => {
612
+ try {
613
+ await runCompanyTransfer({
614
+ companySlug: companyOf(opts),
615
+ action: "decline",
616
+ transferId: opts.transferId,
617
+ reason: opts.reason,
618
+ }, deps);
619
+ }
620
+ catch (err) {
621
+ fail(err);
622
+ }
623
+ });
624
+ transfer
625
+ .command("cancel")
626
+ .description("Cancel a nomination you initiated (initiating owner only).")
627
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
628
+ .option("--transfer-id <id>", "Target a specific transfer. Omit to cancel the currently pending one.")
629
+ .option("--reason <text>", "Note recorded on the audit trail")
630
+ .action(async (opts) => {
631
+ try {
632
+ await runCompanyTransfer({
633
+ companySlug: companyOf(opts),
634
+ action: "cancel",
635
+ transferId: opts.transferId,
636
+ reason: opts.reason,
637
+ }, deps);
638
+ }
639
+ catch (err) {
640
+ fail(err);
641
+ }
642
+ });
643
+ transfer
644
+ .command("status")
645
+ .description("Show the current nomination and the company's transfer history.")
646
+ .option("--company <slug>", "Company slug (resolves to companyUid)")
647
+ .option("--limit <n>", "History page size")
648
+ .option("--cursor <cursor>", "History page cursor")
649
+ .option("--json", "Emit the raw server response")
650
+ .action(async (opts) => {
651
+ try {
652
+ await runCompanyTransferStatus({
653
+ companySlug: companyOf(opts),
654
+ limit: opts.limit,
655
+ cursor: opts.cursor,
656
+ json: opts.json === true,
657
+ });
658
+ }
659
+ catch (err) {
660
+ fail(err);
661
+ }
662
+ });
663
+ }
664
+ //# sourceMappingURL=company-transfer.js.map
@@ -1,6 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { ensureCognitoToken } from "../utils/cognito-session.js";
3
3
  import { vaultApiFetch, getEntityUid } from "../utils/vault-api.js";
4
+ import { registerCompanyTransferCommand } from "./company-transfer.js";
4
5
  /**
5
6
  * Parse a `--flag <true|false>` string into a boolean, or throw. Commander
6
7
  * passes the raw string; we validate strictly so a typo never silently writes
@@ -19,6 +20,8 @@ export function registerCompanyCommand(program) {
19
20
  .command("company")
20
21
  .description("Company-level settings")
21
22
  .option("--company <slug>", "Company slug (resolves to companyUid)");
23
+ // `hq company transfer …` — ownership handover (client-service-pack US-010).
24
+ registerCompanyTransferCommand(company);
22
25
  const settings = company
23
26
  .command("settings")
24
27
  .description("Per-company settings (owner-only)");