@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.
- package/CHANGELOG.md +23 -0
- package/dist/commands/company-transfer.d.ts +185 -0
- package/dist/commands/company-transfer.js +664 -0
- package/dist/commands/company.js +3 -0
- package/dist/commands/core-checkpoint.js +157 -36
- package/dist/commands/search.js +23 -11
- package/dist/lib/search-index/index.d.ts +1 -0
- package/dist/lib/search-index/index.js +41 -5
- package/dist/main.js +8 -4
- package/dist/utils/unexpected-cli-error.d.ts +16 -0
- package/dist/utils/unexpected-cli-error.js +26 -2
- package/package.json +1 -1
|
@@ -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
|
package/dist/commands/company.js
CHANGED
|
@@ -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)");
|