@indigoai-us/hq-cli 5.123.1 → 5.124.0
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 +25 -0
- package/dist/command-catalog.generated.d.ts +104 -0
- package/dist/command-catalog.generated.js +135 -0
- package/dist/commands/billing-sponsor.d.ts +83 -0
- package/dist/commands/billing-sponsor.js +344 -0
- package/dist/commands/billing.d.ts +3 -0
- package/dist/commands/billing.js +5 -0
- package/dist/utils/vault-api.js +106 -52
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.124.0] — 2026-09-20
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `hq billing sponsor` — ask a company to cover your $50/mo HQ Individual plan
|
|
10
|
+
from the terminal, and answer those requests if you run the company.
|
|
11
|
+
`request <company>` asks, `list` shows the queue you can answer or — if you
|
|
12
|
+
are not an admin there — your own single status for that company, and
|
|
13
|
+
`approve` / `decline` / `revoke` / `withdraw` resolve one. Pass `--company`
|
|
14
|
+
to `list` when you are asking about your own state.
|
|
15
|
+
|
|
16
|
+
Approving does not always finish the job: if the company has no card on file,
|
|
17
|
+
HQ accepts the yes but nothing is covered yet, and `approve` says so with the
|
|
18
|
+
link to add one. This is off until HQ turns it on; until then the commands
|
|
19
|
+
say it is not available yet rather than failing with a validation error.
|
|
20
|
+
|
|
21
|
+
## [5.123.2] — 2026-09-19
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Company slug lookup no longer prints `Failed to resolve company slug '…':`
|
|
26
|
+
with nothing after the colon. Transient failures (timeouts, 429, 5xx) are
|
|
27
|
+
retried once, and the error names the HTTP status and says it is a temporary
|
|
28
|
+
lookup failure rather than a missing company.
|
|
29
|
+
|
|
5
30
|
## [5.123.1] — 2026-09-19
|
|
6
31
|
|
|
7
32
|
### Fixed
|
|
@@ -4850,6 +4850,110 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
4850
4850
|
readonly description: "Emit the Checkout URL as JSON";
|
|
4851
4851
|
}];
|
|
4852
4852
|
readonly subcommands: readonly [];
|
|
4853
|
+
}, {
|
|
4854
|
+
readonly name: "sponsor";
|
|
4855
|
+
readonly description: "Ask a company to cover your $50 plan, or answer a request";
|
|
4856
|
+
readonly aliases: readonly [];
|
|
4857
|
+
readonly hidden: false;
|
|
4858
|
+
readonly usage: "[options] [command]";
|
|
4859
|
+
readonly arguments: readonly [];
|
|
4860
|
+
readonly options: readonly [];
|
|
4861
|
+
readonly subcommands: readonly [{
|
|
4862
|
+
readonly name: "request";
|
|
4863
|
+
readonly description: "Ask a company to cover your $50 HQ plan";
|
|
4864
|
+
readonly aliases: readonly [];
|
|
4865
|
+
readonly hidden: false;
|
|
4866
|
+
readonly usage: "[options] <company>";
|
|
4867
|
+
readonly arguments: readonly [{
|
|
4868
|
+
readonly name: "company";
|
|
4869
|
+
readonly required: true;
|
|
4870
|
+
readonly variadic: false;
|
|
4871
|
+
}];
|
|
4872
|
+
readonly options: readonly [{
|
|
4873
|
+
readonly flags: "--json";
|
|
4874
|
+
readonly description: "Emit raw JSON";
|
|
4875
|
+
}];
|
|
4876
|
+
readonly subcommands: readonly [];
|
|
4877
|
+
}, {
|
|
4878
|
+
readonly name: "list";
|
|
4879
|
+
readonly description: "Show sponsorship requests — the company's queue, or your own";
|
|
4880
|
+
readonly aliases: readonly [];
|
|
4881
|
+
readonly hidden: false;
|
|
4882
|
+
readonly usage: "[options]";
|
|
4883
|
+
readonly arguments: readonly [];
|
|
4884
|
+
readonly options: readonly [{
|
|
4885
|
+
readonly flags: "--company <slug>";
|
|
4886
|
+
readonly description: "Company slug (resolves to companyUid)";
|
|
4887
|
+
}, {
|
|
4888
|
+
readonly flags: "--json";
|
|
4889
|
+
readonly description: "Emit raw JSON";
|
|
4890
|
+
}];
|
|
4891
|
+
readonly subcommands: readonly [];
|
|
4892
|
+
}, {
|
|
4893
|
+
readonly name: "approve";
|
|
4894
|
+
readonly description: "Say yes — put someone's $50 plan on the company bill";
|
|
4895
|
+
readonly aliases: readonly [];
|
|
4896
|
+
readonly hidden: false;
|
|
4897
|
+
readonly usage: "[options] <id>";
|
|
4898
|
+
readonly arguments: readonly [{
|
|
4899
|
+
readonly name: "id";
|
|
4900
|
+
readonly required: true;
|
|
4901
|
+
readonly variadic: false;
|
|
4902
|
+
}];
|
|
4903
|
+
readonly options: readonly [{
|
|
4904
|
+
readonly flags: "--json";
|
|
4905
|
+
readonly description: "Emit raw JSON";
|
|
4906
|
+
}];
|
|
4907
|
+
readonly subcommands: readonly [];
|
|
4908
|
+
}, {
|
|
4909
|
+
readonly name: "decline";
|
|
4910
|
+
readonly description: "Say no to a plan sponsorship request";
|
|
4911
|
+
readonly aliases: readonly [];
|
|
4912
|
+
readonly hidden: false;
|
|
4913
|
+
readonly usage: "[options] <id>";
|
|
4914
|
+
readonly arguments: readonly [{
|
|
4915
|
+
readonly name: "id";
|
|
4916
|
+
readonly required: true;
|
|
4917
|
+
readonly variadic: false;
|
|
4918
|
+
}];
|
|
4919
|
+
readonly options: readonly [{
|
|
4920
|
+
readonly flags: "--json";
|
|
4921
|
+
readonly description: "Emit raw JSON";
|
|
4922
|
+
}];
|
|
4923
|
+
readonly subcommands: readonly [];
|
|
4924
|
+
}, {
|
|
4925
|
+
readonly name: "revoke";
|
|
4926
|
+
readonly description: "End a sponsorship the company is currently paying for";
|
|
4927
|
+
readonly aliases: readonly [];
|
|
4928
|
+
readonly hidden: false;
|
|
4929
|
+
readonly usage: "[options] <id>";
|
|
4930
|
+
readonly arguments: readonly [{
|
|
4931
|
+
readonly name: "id";
|
|
4932
|
+
readonly required: true;
|
|
4933
|
+
readonly variadic: false;
|
|
4934
|
+
}];
|
|
4935
|
+
readonly options: readonly [{
|
|
4936
|
+
readonly flags: "--json";
|
|
4937
|
+
readonly description: "Emit raw JSON";
|
|
4938
|
+
}];
|
|
4939
|
+
readonly subcommands: readonly [];
|
|
4940
|
+
}, {
|
|
4941
|
+
readonly name: "withdraw";
|
|
4942
|
+
readonly description: "Take back a sponsorship request you made";
|
|
4943
|
+
readonly aliases: readonly [];
|
|
4944
|
+
readonly hidden: false;
|
|
4945
|
+
readonly usage: "[options] <id>";
|
|
4946
|
+
readonly arguments: readonly [{
|
|
4947
|
+
readonly name: "id";
|
|
4948
|
+
readonly required: true;
|
|
4949
|
+
readonly variadic: false;
|
|
4950
|
+
}];
|
|
4951
|
+
readonly options: readonly [{
|
|
4952
|
+
readonly flags: "--json";
|
|
4953
|
+
readonly description: "Emit raw JSON";
|
|
4954
|
+
}];
|
|
4955
|
+
readonly subcommands: readonly [];
|
|
4956
|
+
}];
|
|
4853
4957
|
}];
|
|
4854
4958
|
}, {
|
|
4855
4959
|
readonly name: "core";
|
|
@@ -6268,6 +6268,141 @@ export const COMMAND_CATALOG = [
|
|
|
6268
6268
|
}
|
|
6269
6269
|
],
|
|
6270
6270
|
"subcommands": []
|
|
6271
|
+
},
|
|
6272
|
+
{
|
|
6273
|
+
"name": "sponsor",
|
|
6274
|
+
"description": "Ask a company to cover your $50 plan, or answer a request",
|
|
6275
|
+
"aliases": [],
|
|
6276
|
+
"hidden": false,
|
|
6277
|
+
"usage": "[options] [command]",
|
|
6278
|
+
"arguments": [],
|
|
6279
|
+
"options": [],
|
|
6280
|
+
"subcommands": [
|
|
6281
|
+
{
|
|
6282
|
+
"name": "request",
|
|
6283
|
+
"description": "Ask a company to cover your $50 HQ plan",
|
|
6284
|
+
"aliases": [],
|
|
6285
|
+
"hidden": false,
|
|
6286
|
+
"usage": "[options] <company>",
|
|
6287
|
+
"arguments": [
|
|
6288
|
+
{
|
|
6289
|
+
"name": "company",
|
|
6290
|
+
"required": true,
|
|
6291
|
+
"variadic": false
|
|
6292
|
+
}
|
|
6293
|
+
],
|
|
6294
|
+
"options": [
|
|
6295
|
+
{
|
|
6296
|
+
"flags": "--json",
|
|
6297
|
+
"description": "Emit raw JSON"
|
|
6298
|
+
}
|
|
6299
|
+
],
|
|
6300
|
+
"subcommands": []
|
|
6301
|
+
},
|
|
6302
|
+
{
|
|
6303
|
+
"name": "list",
|
|
6304
|
+
"description": "Show sponsorship requests — the company's queue, or your own",
|
|
6305
|
+
"aliases": [],
|
|
6306
|
+
"hidden": false,
|
|
6307
|
+
"usage": "[options]",
|
|
6308
|
+
"arguments": [],
|
|
6309
|
+
"options": [
|
|
6310
|
+
{
|
|
6311
|
+
"flags": "--company <slug>",
|
|
6312
|
+
"description": "Company slug (resolves to companyUid)"
|
|
6313
|
+
},
|
|
6314
|
+
{
|
|
6315
|
+
"flags": "--json",
|
|
6316
|
+
"description": "Emit raw JSON"
|
|
6317
|
+
}
|
|
6318
|
+
],
|
|
6319
|
+
"subcommands": []
|
|
6320
|
+
},
|
|
6321
|
+
{
|
|
6322
|
+
"name": "approve",
|
|
6323
|
+
"description": "Say yes — put someone's $50 plan on the company bill",
|
|
6324
|
+
"aliases": [],
|
|
6325
|
+
"hidden": false,
|
|
6326
|
+
"usage": "[options] <id>",
|
|
6327
|
+
"arguments": [
|
|
6328
|
+
{
|
|
6329
|
+
"name": "id",
|
|
6330
|
+
"required": true,
|
|
6331
|
+
"variadic": false
|
|
6332
|
+
}
|
|
6333
|
+
],
|
|
6334
|
+
"options": [
|
|
6335
|
+
{
|
|
6336
|
+
"flags": "--json",
|
|
6337
|
+
"description": "Emit raw JSON"
|
|
6338
|
+
}
|
|
6339
|
+
],
|
|
6340
|
+
"subcommands": []
|
|
6341
|
+
},
|
|
6342
|
+
{
|
|
6343
|
+
"name": "decline",
|
|
6344
|
+
"description": "Say no to a plan sponsorship request",
|
|
6345
|
+
"aliases": [],
|
|
6346
|
+
"hidden": false,
|
|
6347
|
+
"usage": "[options] <id>",
|
|
6348
|
+
"arguments": [
|
|
6349
|
+
{
|
|
6350
|
+
"name": "id",
|
|
6351
|
+
"required": true,
|
|
6352
|
+
"variadic": false
|
|
6353
|
+
}
|
|
6354
|
+
],
|
|
6355
|
+
"options": [
|
|
6356
|
+
{
|
|
6357
|
+
"flags": "--json",
|
|
6358
|
+
"description": "Emit raw JSON"
|
|
6359
|
+
}
|
|
6360
|
+
],
|
|
6361
|
+
"subcommands": []
|
|
6362
|
+
},
|
|
6363
|
+
{
|
|
6364
|
+
"name": "revoke",
|
|
6365
|
+
"description": "End a sponsorship the company is currently paying for",
|
|
6366
|
+
"aliases": [],
|
|
6367
|
+
"hidden": false,
|
|
6368
|
+
"usage": "[options] <id>",
|
|
6369
|
+
"arguments": [
|
|
6370
|
+
{
|
|
6371
|
+
"name": "id",
|
|
6372
|
+
"required": true,
|
|
6373
|
+
"variadic": false
|
|
6374
|
+
}
|
|
6375
|
+
],
|
|
6376
|
+
"options": [
|
|
6377
|
+
{
|
|
6378
|
+
"flags": "--json",
|
|
6379
|
+
"description": "Emit raw JSON"
|
|
6380
|
+
}
|
|
6381
|
+
],
|
|
6382
|
+
"subcommands": []
|
|
6383
|
+
},
|
|
6384
|
+
{
|
|
6385
|
+
"name": "withdraw",
|
|
6386
|
+
"description": "Take back a sponsorship request you made",
|
|
6387
|
+
"aliases": [],
|
|
6388
|
+
"hidden": false,
|
|
6389
|
+
"usage": "[options] <id>",
|
|
6390
|
+
"arguments": [
|
|
6391
|
+
{
|
|
6392
|
+
"name": "id",
|
|
6393
|
+
"required": true,
|
|
6394
|
+
"variadic": false
|
|
6395
|
+
}
|
|
6396
|
+
],
|
|
6397
|
+
"options": [
|
|
6398
|
+
{
|
|
6399
|
+
"flags": "--json",
|
|
6400
|
+
"description": "Emit raw JSON"
|
|
6401
|
+
}
|
|
6402
|
+
],
|
|
6403
|
+
"subcommands": []
|
|
6404
|
+
}
|
|
6405
|
+
]
|
|
6271
6406
|
}
|
|
6272
6407
|
]
|
|
6273
6408
|
},
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq billing sponsor` — the individual-pull half of plan sponsorship, from the
|
|
3
|
+
* terminal ("ask a company to sponsor me", hq-pro #3609).
|
|
4
|
+
*
|
|
5
|
+
* A person who is over a personal limit has exactly three ways out: pay $50
|
|
6
|
+
* themselves, reduce what they store, or ask a company to cover the $50 plan
|
|
7
|
+
* line. This command is the third one, plus the admin side that answers it.
|
|
8
|
+
*
|
|
9
|
+
* hq billing sponsor request <company> — ask that company to cover my plan
|
|
10
|
+
* hq billing sponsor list — the queue (admins), or my own
|
|
11
|
+
* single status (everyone else)
|
|
12
|
+
* hq billing sponsor approve <id> — admin: say yes
|
|
13
|
+
* hq billing sponsor decline <id> — admin: say no
|
|
14
|
+
* hq billing sponsor revoke <id> — admin: end an active sponsorship
|
|
15
|
+
* hq billing sponsor withdraw <id> — me: take back my own request
|
|
16
|
+
*
|
|
17
|
+
* Every subcommand talks to the SAME hq-pro routes the console uses
|
|
18
|
+
* (`/v1/billing/sponsorship/...`). hq-pro is the authoritative gate on all of
|
|
19
|
+
* them: it resolves the caller's person id and the company's admin set
|
|
20
|
+
* server-side, so nothing here is trusted from the client.
|
|
21
|
+
*
|
|
22
|
+
* FEATURE SWITCH. The `plan` request kind only exists while hq-pro's
|
|
23
|
+
* `PLAN_SPONSORSHIP_REQUESTS_ENABLED` switch is on. With the switch off the
|
|
24
|
+
* backend does not recognise `plan` and answers 400 `MISSING_FIELDS` — which
|
|
25
|
+
* would read as "you typed something wrong". We translate that one case into a
|
|
26
|
+
* plain "not available yet" line so a person never sees a validation error for
|
|
27
|
+
* a feature that simply is not turned on.
|
|
28
|
+
*/
|
|
29
|
+
import { Command } from "commander";
|
|
30
|
+
/** The request kind this command files. Matches hq-pro's contract. */
|
|
31
|
+
export declare const PLAN_RESOURCE: "plan";
|
|
32
|
+
/** What a person is told when hq-pro's plan-sponsorship switch is off. */
|
|
33
|
+
export declare const FEATURE_OFF_MESSAGE = "Asking a company to sponsor your plan isn't available yet.";
|
|
34
|
+
/** What v1 sponsorship covers, stated wherever a person confirms or reads it. */
|
|
35
|
+
export declare const COVERAGE_SENTENCE = "Your company pays your $50 plan. Storage above your plan's allowance is still yours.";
|
|
36
|
+
/** What a person is told while an approval waits on the sponsor's card. */
|
|
37
|
+
export declare const HELD_FOR_CARD_SENTENCE = "Your company said yes and is adding a card. Your limits still apply until then.";
|
|
38
|
+
/** One request row as hq-pro persists and returns it. */
|
|
39
|
+
export interface SponsorshipRequestRecord {
|
|
40
|
+
requestId: string;
|
|
41
|
+
companyUid: string;
|
|
42
|
+
requestedBy: string;
|
|
43
|
+
resourceId?: string;
|
|
44
|
+
resource: "outpost" | "agent" | "plan";
|
|
45
|
+
status: "pending" | "active" | "declined" | "pending-sponsor-billing" | "expired";
|
|
46
|
+
payer: "person" | "company";
|
|
47
|
+
requestedAt: string;
|
|
48
|
+
updatedAt: string;
|
|
49
|
+
}
|
|
50
|
+
/** An hq-pro error body: `{ error, code }` on every non-2xx these routes send. */
|
|
51
|
+
interface ApiErrorBody {
|
|
52
|
+
error?: string;
|
|
53
|
+
message?: string;
|
|
54
|
+
code?: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Turn an hq-pro non-2xx into one plain sentence.
|
|
58
|
+
*
|
|
59
|
+
* The switch-off 400 is the important translation: with the feature off,
|
|
60
|
+
* `plan` is an unrecognised resource kind and the generic body reads
|
|
61
|
+
* "companyUid and resource ('outpost'|'agent') are required", which tells a
|
|
62
|
+
* person nothing true about what happened.
|
|
63
|
+
*/
|
|
64
|
+
export declare function messageForError(status: number, body: ApiErrorBody): string;
|
|
65
|
+
/**
|
|
66
|
+
* The member self-read's shape. hq-pro answers
|
|
67
|
+
* `GET /v1/billing/sponsorship/mine?companyUid=…&resource=…` with ONE status —
|
|
68
|
+
* not a list — because the route is a self-scoped "am I covered" question.
|
|
69
|
+
* `active` is true only for an approved COMPANY-paid row; everything else
|
|
70
|
+
* (never asked, still pending, declined) comes back `active: false` with null
|
|
71
|
+
* fields.
|
|
72
|
+
*/
|
|
73
|
+
export interface SponsorshipSelfState {
|
|
74
|
+
active: boolean;
|
|
75
|
+
status: string | null;
|
|
76
|
+
payer: "person" | "company" | null;
|
|
77
|
+
requestId: string | null;
|
|
78
|
+
}
|
|
79
|
+
/** Plain label for a request status. Never shows the raw enum. */
|
|
80
|
+
export declare function statusLabel(status: string): string;
|
|
81
|
+
export declare function registerBillingSponsorCommands(billing: Command): void;
|
|
82
|
+
export {};
|
|
83
|
+
//# sourceMappingURL=billing-sponsor.d.ts.map
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq billing sponsor` — the individual-pull half of plan sponsorship, from the
|
|
3
|
+
* terminal ("ask a company to sponsor me", hq-pro #3609).
|
|
4
|
+
*
|
|
5
|
+
* A person who is over a personal limit has exactly three ways out: pay $50
|
|
6
|
+
* themselves, reduce what they store, or ask a company to cover the $50 plan
|
|
7
|
+
* line. This command is the third one, plus the admin side that answers it.
|
|
8
|
+
*
|
|
9
|
+
* hq billing sponsor request <company> — ask that company to cover my plan
|
|
10
|
+
* hq billing sponsor list — the queue (admins), or my own
|
|
11
|
+
* single status (everyone else)
|
|
12
|
+
* hq billing sponsor approve <id> — admin: say yes
|
|
13
|
+
* hq billing sponsor decline <id> — admin: say no
|
|
14
|
+
* hq billing sponsor revoke <id> — admin: end an active sponsorship
|
|
15
|
+
* hq billing sponsor withdraw <id> — me: take back my own request
|
|
16
|
+
*
|
|
17
|
+
* Every subcommand talks to the SAME hq-pro routes the console uses
|
|
18
|
+
* (`/v1/billing/sponsorship/...`). hq-pro is the authoritative gate on all of
|
|
19
|
+
* them: it resolves the caller's person id and the company's admin set
|
|
20
|
+
* server-side, so nothing here is trusted from the client.
|
|
21
|
+
*
|
|
22
|
+
* FEATURE SWITCH. The `plan` request kind only exists while hq-pro's
|
|
23
|
+
* `PLAN_SPONSORSHIP_REQUESTS_ENABLED` switch is on. With the switch off the
|
|
24
|
+
* backend does not recognise `plan` and answers 400 `MISSING_FIELDS` — which
|
|
25
|
+
* would read as "you typed something wrong". We translate that one case into a
|
|
26
|
+
* plain "not available yet" line so a person never sees a validation error for
|
|
27
|
+
* a feature that simply is not turned on.
|
|
28
|
+
*/
|
|
29
|
+
import chalk from "chalk";
|
|
30
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
31
|
+
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
32
|
+
/** The request kind this command files. Matches hq-pro's contract. */
|
|
33
|
+
export const PLAN_RESOURCE = "plan";
|
|
34
|
+
/** What a person is told when hq-pro's plan-sponsorship switch is off. */
|
|
35
|
+
export const FEATURE_OFF_MESSAGE = "Asking a company to sponsor your plan isn't available yet.";
|
|
36
|
+
/** What v1 sponsorship covers, stated wherever a person confirms or reads it. */
|
|
37
|
+
export const COVERAGE_SENTENCE = "Your company pays your $50 plan. Storage above your plan's allowance is still yours.";
|
|
38
|
+
/** What a person is told while an approval waits on the sponsor's card. */
|
|
39
|
+
export const HELD_FOR_CARD_SENTENCE = "Your company said yes and is adding a card. Your limits still apply until then.";
|
|
40
|
+
/**
|
|
41
|
+
* An error we already have plain wording for. `expected` marks it as a normal
|
|
42
|
+
* outcome (a decline, a rate limit) rather than a fault, matching the flag the
|
|
43
|
+
* rest of the CLI uses to keep Sentry quiet.
|
|
44
|
+
*/
|
|
45
|
+
class SponsorError extends Error {
|
|
46
|
+
expected = true;
|
|
47
|
+
}
|
|
48
|
+
function fail(err) {
|
|
49
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Turn an hq-pro non-2xx into one plain sentence.
|
|
54
|
+
*
|
|
55
|
+
* The switch-off 400 is the important translation: with the feature off,
|
|
56
|
+
* `plan` is an unrecognised resource kind and the generic body reads
|
|
57
|
+
* "companyUid and resource ('outpost'|'agent') are required", which tells a
|
|
58
|
+
* person nothing true about what happened.
|
|
59
|
+
*/
|
|
60
|
+
export function messageForError(status, body) {
|
|
61
|
+
const code = body.code;
|
|
62
|
+
if (status === 400 && code === "MISSING_FIELDS")
|
|
63
|
+
return FEATURE_OFF_MESSAGE;
|
|
64
|
+
if (status === 401)
|
|
65
|
+
return "Your sign-in has expired. Run `hq login` and try again.";
|
|
66
|
+
if (status === 403 && code === "SPONSOR_PLAN_REQUIRED") {
|
|
67
|
+
return "That company's plan can't cover an individual plan. Only HQ Workforce and Enterprise companies can.";
|
|
68
|
+
}
|
|
69
|
+
if (status === 403) {
|
|
70
|
+
return "You're not allowed to do that. Only an owner or admin of the company can answer a sponsorship request.";
|
|
71
|
+
}
|
|
72
|
+
if (status === 404)
|
|
73
|
+
return "No sponsorship request with that id.";
|
|
74
|
+
if (status === 409 && code === "DUPLICATE_PENDING_REQUEST") {
|
|
75
|
+
return "You've already asked that company. They just need to answer.";
|
|
76
|
+
}
|
|
77
|
+
if (status === 429 && code === "PLAN_REQUEST_RATE_LIMITED") {
|
|
78
|
+
return "You asked that company in the last 24 hours. Give them a day before asking again.";
|
|
79
|
+
}
|
|
80
|
+
return body.error ?? body.message ?? `Request failed (${status}).`;
|
|
81
|
+
}
|
|
82
|
+
/** Call an hq-pro sponsorship route, or throw a sentence a person can read. */
|
|
83
|
+
async function callApi(token, path, init = {}) {
|
|
84
|
+
const res = await vaultApiFetch({ token, path, ...init });
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
const body = (await res.json().catch(() => ({})));
|
|
87
|
+
throw new SponsorError(messageForError(res.status, body));
|
|
88
|
+
}
|
|
89
|
+
return (await res.json());
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Read the caller's OWN plan-sponsorship state for one company.
|
|
93
|
+
*
|
|
94
|
+
* Two non-2xx cases mean the same thing to a person and must NOT fall through
|
|
95
|
+
* to {@link messageForError}: a 400 `MISSING_FIELDS` (hq-pro does not accept the
|
|
96
|
+
* `plan` resource kind yet) and a 404 (an hq-pro that predates this route at
|
|
97
|
+
* all). Both are "the feature is not on", never "no request with that id" —
|
|
98
|
+
* there is no id in a list command for that sentence to be about.
|
|
99
|
+
*/
|
|
100
|
+
async function readSelfState(token, companyUid) {
|
|
101
|
+
const res = await vaultApiFetch({
|
|
102
|
+
token,
|
|
103
|
+
path: "/v1/billing/sponsorship/mine",
|
|
104
|
+
query: { companyUid, resource: PLAN_RESOURCE },
|
|
105
|
+
});
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
const body = (await res.json().catch(() => ({})));
|
|
108
|
+
if (res.status === 404 || (res.status === 400 && body.code === "MISSING_FIELDS")) {
|
|
109
|
+
throw new SponsorError(FEATURE_OFF_MESSAGE);
|
|
110
|
+
}
|
|
111
|
+
throw new SponsorError(messageForError(res.status, body));
|
|
112
|
+
}
|
|
113
|
+
return (await res.json());
|
|
114
|
+
}
|
|
115
|
+
/** Plain label for a request status. Never shows the raw enum. */
|
|
116
|
+
export function statusLabel(status) {
|
|
117
|
+
switch (status) {
|
|
118
|
+
case "pending":
|
|
119
|
+
return "Waiting for an answer";
|
|
120
|
+
case "pending-sponsor-billing":
|
|
121
|
+
return "Said yes — waiting on their card";
|
|
122
|
+
case "active":
|
|
123
|
+
return "Covered by the company";
|
|
124
|
+
case "declined":
|
|
125
|
+
return "Not covered";
|
|
126
|
+
case "expired":
|
|
127
|
+
return "Expired";
|
|
128
|
+
default:
|
|
129
|
+
return status;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** One line per request, in the same column order everywhere. */
|
|
133
|
+
function renderRow(r) {
|
|
134
|
+
const when = r.requestedAt.slice(0, 10);
|
|
135
|
+
return ` ${chalk.bold(r.requestId)} ${statusLabel(r.status).padEnd(32)} asked ${when}`;
|
|
136
|
+
}
|
|
137
|
+
/** Only plan rows belong on this surface; outpost/agent have their own. */
|
|
138
|
+
function planRowsOf(rows) {
|
|
139
|
+
return rows.filter((r) => r.resource === PLAN_RESOURCE);
|
|
140
|
+
}
|
|
141
|
+
/** Shared body of approve / decline / revoke / withdraw. */
|
|
142
|
+
async function transition(requestId, op) {
|
|
143
|
+
const token = await ensureCognitoToken();
|
|
144
|
+
const data = await callApi(token, `/v1/billing/sponsorship/request/${op}`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
body: { requestId },
|
|
147
|
+
});
|
|
148
|
+
return { ...data.request, code: data.code, addCardUrl: data.addCardUrl };
|
|
149
|
+
}
|
|
150
|
+
export function registerBillingSponsorCommands(billing) {
|
|
151
|
+
const sponsor = billing
|
|
152
|
+
.command("sponsor")
|
|
153
|
+
.description("Ask a company to cover your $50 plan, or answer a request");
|
|
154
|
+
sponsor
|
|
155
|
+
.command("request")
|
|
156
|
+
.argument("<company>", "Company slug or uid to ask")
|
|
157
|
+
.description("Ask a company to cover your $50 HQ plan")
|
|
158
|
+
.option("--json", "Emit raw JSON")
|
|
159
|
+
.action(async function (company, opts) {
|
|
160
|
+
try {
|
|
161
|
+
const token = await ensureCognitoToken();
|
|
162
|
+
const companyUid = await getCompanyUid(token, company);
|
|
163
|
+
const data = await callApi(token, "/v1/billing/sponsorship/request", { method: "POST", body: { companyUid, resource: PLAN_RESOURCE } });
|
|
164
|
+
if (opts.json) {
|
|
165
|
+
process.stdout.write(JSON.stringify(data.request, null, 2) + "\n");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
console.log(`Asked ${company} to cover your $50 HQ plan.`);
|
|
169
|
+
console.log(COVERAGE_SENTENCE);
|
|
170
|
+
console.log(`An owner or admin there answers next. Check with \`hq billing sponsor list\`.`);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
fail(err);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
sponsor
|
|
177
|
+
.command("list")
|
|
178
|
+
.description("Show sponsorship requests — the company's queue, or your own")
|
|
179
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
180
|
+
.option("--json", "Emit raw JSON")
|
|
181
|
+
.action(async function (opts) {
|
|
182
|
+
try {
|
|
183
|
+
const token = await ensureCognitoToken();
|
|
184
|
+
// A company the caller cannot resolve is not fatal here. The person
|
|
185
|
+
// this feature is for often belongs to no company at all, and they
|
|
186
|
+
// still need to be told what to do next.
|
|
187
|
+
const named = opts.company ?? billing.opts().company;
|
|
188
|
+
const companyUid = await getCompanyUid(token, named).catch(() => null);
|
|
189
|
+
// The admin queue is the richer view. A non-admin is refused it, so we
|
|
190
|
+
// fall back to the member SELF-READ — a different route with a
|
|
191
|
+
// different answer: hq-pro returns ONE status for that person and that
|
|
192
|
+
// company, not a list. Rendering it as a list (or reusing the list's
|
|
193
|
+
// "not found" wording) is what made this read wrong.
|
|
194
|
+
let rows = null;
|
|
195
|
+
if (companyUid) {
|
|
196
|
+
try {
|
|
197
|
+
const data = await callApi(token, "/v1/billing/sponsorship/requests", { query: { companyUid } });
|
|
198
|
+
rows = planRowsOf(data.requests);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (!(err instanceof SponsorError))
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (rows === null) {
|
|
206
|
+
// The self-read is company-scoped by contract, so without a company
|
|
207
|
+
// there is nothing to ask for. Say which flag fixes it.
|
|
208
|
+
if (!companyUid) {
|
|
209
|
+
const line = named
|
|
210
|
+
? `Couldn't find a company called ${named}.`
|
|
211
|
+
: "Name the company you asked: `hq billing sponsor list --company <slug>`.";
|
|
212
|
+
if (opts.json) {
|
|
213
|
+
process.stdout.write(JSON.stringify({ error: line }, null, 2) + "\n");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
console.log(line);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const state = await readSelfState(token, companyUid);
|
|
220
|
+
if (opts.json) {
|
|
221
|
+
process.stdout.write(JSON.stringify(state, null, 2) + "\n");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const where = named ?? "that company";
|
|
225
|
+
if (!state.active) {
|
|
226
|
+
console.log(`No company is covering your $50 HQ plan right now.`);
|
|
227
|
+
console.log(`If you asked ${where}, they haven't answered yet — an owner or admin there answers next.`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
console.log(`Your $50 HQ plan: ${statusLabel(state.status ?? "active")}`);
|
|
231
|
+
if (state.requestId)
|
|
232
|
+
console.log(` ${chalk.bold(state.requestId)}`);
|
|
233
|
+
console.log(COVERAGE_SENTENCE);
|
|
234
|
+
if (state.status === "pending-sponsor-billing") {
|
|
235
|
+
console.log(`\n${HELD_FOR_CARD_SENTENCE}`);
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (opts.json) {
|
|
240
|
+
process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (rows.length === 0) {
|
|
244
|
+
console.log("No one has asked this company to cover their plan.");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
console.log("Plan sponsorship requests:");
|
|
248
|
+
for (const r of rows)
|
|
249
|
+
console.log(renderRow(r));
|
|
250
|
+
if (rows.some((r) => r.status === "pending-sponsor-billing")) {
|
|
251
|
+
console.log(`\n${chalk.yellow("Add a card")} to start the requests you said yes to. Nothing is covered until then.`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
fail(err);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
sponsor
|
|
259
|
+
.command("approve")
|
|
260
|
+
.argument("<id>", "Request id")
|
|
261
|
+
.description("Say yes — put someone's $50 plan on the company bill")
|
|
262
|
+
.option("--json", "Emit raw JSON")
|
|
263
|
+
.action(async function (id, opts) {
|
|
264
|
+
try {
|
|
265
|
+
const result = await transition(id, "approve");
|
|
266
|
+
if (opts.json) {
|
|
267
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (result.status === "pending-sponsor-billing") {
|
|
271
|
+
console.log("Approved. Add a card to start this sponsorship — nothing is covered until then.");
|
|
272
|
+
if (result.addCardUrl)
|
|
273
|
+
console.log(` ${chalk.cyan(result.addCardUrl)}`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
console.log("Approved. Their $50 plan is now on the company bill.");
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
fail(err);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
sponsor
|
|
283
|
+
.command("decline")
|
|
284
|
+
.argument("<id>", "Request id")
|
|
285
|
+
.description("Say no to a plan sponsorship request")
|
|
286
|
+
.option("--json", "Emit raw JSON")
|
|
287
|
+
.action(async function (id, opts) {
|
|
288
|
+
try {
|
|
289
|
+
const result = await transition(id, "decline");
|
|
290
|
+
if (opts.json) {
|
|
291
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
console.log("Declined. Their plan stays theirs to pay or reduce.");
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
fail(err);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
sponsor
|
|
301
|
+
.command("revoke")
|
|
302
|
+
.argument("<id>", "Request id")
|
|
303
|
+
.description("End a sponsorship the company is currently paying for")
|
|
304
|
+
.option("--json", "Emit raw JSON")
|
|
305
|
+
.action(async function (id, opts) {
|
|
306
|
+
try {
|
|
307
|
+
// Revoke and decline are the SAME hq-pro route: an active row flips to
|
|
308
|
+
// declined and the plan goes back to the person. Two names here because
|
|
309
|
+
// "revoke a sponsorship you're paying for" and "decline a request you
|
|
310
|
+
// haven't answered" are different things to the admin doing them.
|
|
311
|
+
const result = await transition(id, "decline");
|
|
312
|
+
if (opts.json) {
|
|
313
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
console.log("Ended. Their $50 plan goes back to them from the next bill.");
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
fail(err);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
sponsor
|
|
323
|
+
.command("withdraw")
|
|
324
|
+
.argument("<id>", "Request id")
|
|
325
|
+
.description("Take back a sponsorship request you made")
|
|
326
|
+
.option("--json", "Emit raw JSON")
|
|
327
|
+
.action(async function (id, opts) {
|
|
328
|
+
try {
|
|
329
|
+
// hq-pro lets a person decline THEIR OWN plan row without being an admin
|
|
330
|
+
// (it resolves the caller's person id server-side and compares it to the
|
|
331
|
+
// row's requester), so withdraw is the same route scoped to yourself.
|
|
332
|
+
const result = await transition(id, "decline");
|
|
333
|
+
if (opts.json) {
|
|
334
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
console.log("Withdrawn. You can pay the $50 yourself, reduce what you store, or ask another company.");
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
fail(err);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
//# sourceMappingURL=billing-sponsor.js.map
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* Subcommands:
|
|
12
12
|
* hq billing status [--company <slug>] — subscription + card on file
|
|
13
13
|
* hq billing checkout [--company <slug>] [--personal] — mint a card-capture link
|
|
14
|
+
* hq billing sponsor … — ask a company to cover
|
|
15
|
+
* your $50 plan, or
|
|
16
|
+
* answer such a request
|
|
14
17
|
*/
|
|
15
18
|
import { Command } from "commander";
|
|
16
19
|
export declare function registerBillingCommand(program: Command): void;
|
package/dist/commands/billing.js
CHANGED
|
@@ -11,12 +11,16 @@
|
|
|
11
11
|
* Subcommands:
|
|
12
12
|
* hq billing status [--company <slug>] — subscription + card on file
|
|
13
13
|
* hq billing checkout [--company <slug>] [--personal] — mint a card-capture link
|
|
14
|
+
* hq billing sponsor … — ask a company to cover
|
|
15
|
+
* your $50 plan, or
|
|
16
|
+
* answer such a request
|
|
14
17
|
*/
|
|
15
18
|
import chalk from "chalk";
|
|
16
19
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
17
20
|
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
18
21
|
import { mintPaymentLink } from "../utils/billing-gate.js";
|
|
19
22
|
import { upgradeToTeam } from "../utils/team-upgrade.js";
|
|
23
|
+
import { registerBillingSponsorCommands } from "./billing-sponsor.js";
|
|
20
24
|
function fail(err) {
|
|
21
25
|
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
22
26
|
process.exit(1);
|
|
@@ -126,5 +130,6 @@ export function registerBillingCommand(program) {
|
|
|
126
130
|
fail(err);
|
|
127
131
|
}
|
|
128
132
|
});
|
|
133
|
+
registerBillingSponsorCommands(billing);
|
|
129
134
|
}
|
|
130
135
|
//# sourceMappingURL=billing.js.map
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -320,16 +320,37 @@ async function resolveCompanyByUid(token, uid) {
|
|
|
320
320
|
if (!res.ok) {
|
|
321
321
|
raiseIfUnauthorized(res);
|
|
322
322
|
const body = (await res.json().catch(() => ({})));
|
|
323
|
-
throw new Error(`Failed to resolve company '${uid}': ${body
|
|
323
|
+
throw new Error(`Failed to resolve company '${uid}': ${formatSlugLookupReason(res, body)}`);
|
|
324
324
|
}
|
|
325
325
|
const data = (await res.json());
|
|
326
326
|
return data.entity.uid;
|
|
327
327
|
}
|
|
328
|
+
/**
|
|
329
|
+
* Node's fetch (undici) often leaves `statusText` empty, and some vault
|
|
330
|
+
* error bodies send `error: ""` or omit it. `??` does not treat "" as missing,
|
|
331
|
+
* so `body.error ?? res.statusText` produced
|
|
332
|
+
* `Failed to resolve company slug '…':` with nothing after the colon
|
|
333
|
+
* (Shepherd #2306). Never return an empty reason: prefer a non-blank body
|
|
334
|
+
* error, then statusText, then `HTTP <status>`.
|
|
335
|
+
*/
|
|
336
|
+
function formatSlugLookupReason(res, body) {
|
|
337
|
+
const fromBody = typeof body.error === "string" ? body.error.trim() : "";
|
|
338
|
+
if (fromBody)
|
|
339
|
+
return fromBody;
|
|
340
|
+
const fromStatus = (res.statusText ?? "").trim();
|
|
341
|
+
if (fromStatus)
|
|
342
|
+
return fromStatus;
|
|
343
|
+
return `HTTP ${res.status}`;
|
|
344
|
+
}
|
|
345
|
+
function isTransientSlugLookupStatus(status) {
|
|
346
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
347
|
+
}
|
|
328
348
|
/**
|
|
329
349
|
* Resolve a company slug WITHIN THE CALLER'S NAMESPACE via the server's
|
|
330
350
|
* caller-scoped `/entity/check-slug/me` endpoint. Returns the caller's own
|
|
331
|
-
* company uid for that slug,
|
|
332
|
-
* to no company with that slug
|
|
351
|
+
* company uid for that slug, `miss` when the caller owns / actively belongs
|
|
352
|
+
* to no company with that slug, or `transient` when the lookup itself failed
|
|
353
|
+
* (timeout, 429, 5xx) after one retry.
|
|
333
354
|
*
|
|
334
355
|
* This is the load-bearing fix for DEV-1742. Under hq-pro's per-user-namespace
|
|
335
356
|
* model two unrelated tenants may each hold "acme", so the legacy GLOBAL
|
|
@@ -344,25 +365,63 @@ async function resolveCompanyByUid(token, uid) {
|
|
|
344
365
|
* the same primitive hq-cloud's `context.ts` already resolves slugs through.
|
|
345
366
|
*/
|
|
346
367
|
async function resolveSlugInCallerNamespace(token, slug) {
|
|
347
|
-
const
|
|
368
|
+
const attempt = async () => {
|
|
369
|
+
const res = await vaultApiFetch({
|
|
370
|
+
token,
|
|
371
|
+
path: "/entity/check-slug/me",
|
|
372
|
+
query: { type: "company", slug },
|
|
373
|
+
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
374
|
+
});
|
|
375
|
+
if (!res.ok) {
|
|
376
|
+
raiseIfUnauthorized(res);
|
|
377
|
+
// Namespace lookup unavailable (e.g. membership table not configured →
|
|
378
|
+
// 503, or the caller has no person entity). A 401 short-circuits above
|
|
379
|
+
// because the token is bad and the global fallback would only 401 again.
|
|
380
|
+
// Transient statuses are retried once below; other non-2xx still mean
|
|
381
|
+
// "couldn't resolve here" and fall back to the global lookup.
|
|
382
|
+
// vaultApiFetch already recorded the non-2xx as a Sentry breadcrumb.
|
|
383
|
+
if (isTransientSlugLookupStatus(res.status)) {
|
|
384
|
+
return { kind: "transient" };
|
|
385
|
+
}
|
|
386
|
+
return { kind: "miss" };
|
|
387
|
+
}
|
|
388
|
+
const data = (await res.json());
|
|
389
|
+
// `available: false` carries the caller's own company holding the slug.
|
|
390
|
+
const uid = data.conflictingCompanyUid;
|
|
391
|
+
return uid ? { kind: "hit", uid } : { kind: "miss" };
|
|
392
|
+
};
|
|
393
|
+
const run = async () => {
|
|
394
|
+
try {
|
|
395
|
+
return await attempt();
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
if (networkTransportErrorCode(err) === null)
|
|
399
|
+
throw err;
|
|
400
|
+
return { kind: "transient" };
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
const first = await run();
|
|
404
|
+
if (first.kind !== "transient")
|
|
405
|
+
return first;
|
|
406
|
+
return run();
|
|
407
|
+
}
|
|
408
|
+
async function fetchGlobalCompanyBySlug(token, ref) {
|
|
409
|
+
const attempt = () => vaultApiFetch({
|
|
348
410
|
token,
|
|
349
|
-
path:
|
|
350
|
-
query: { type: 'company', slug },
|
|
411
|
+
path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
|
|
351
412
|
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
352
413
|
});
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
// because the token is bad and the global fallback would only 401 again;
|
|
358
|
-
// other non-2xx statuses signal "couldn't resolve here" and let the caller
|
|
359
|
-
// fall back to the global lookup. vaultApiFetch already recorded the
|
|
360
|
-
// non-2xx as a Sentry breadcrumb, so this is not a silent swallow.
|
|
361
|
-
return null;
|
|
414
|
+
try {
|
|
415
|
+
const res = await attempt();
|
|
416
|
+
if (res.ok || !isTransientSlugLookupStatus(res.status))
|
|
417
|
+
return res;
|
|
362
418
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
419
|
+
catch (err) {
|
|
420
|
+
if (networkTransportErrorCode(err) === null)
|
|
421
|
+
throw err;
|
|
422
|
+
return attempt();
|
|
423
|
+
}
|
|
424
|
+
return attempt();
|
|
366
425
|
}
|
|
367
426
|
async function resolveCompanyUid(token, ref) {
|
|
368
427
|
// Silent escape hatch: a `cmp_` ref is a uid, not a slug — resolve directly.
|
|
@@ -379,39 +438,25 @@ async function resolveCompanyUid(token, ref) {
|
|
|
379
438
|
// caller's OWN company (unique within their namespace by the invariant
|
|
380
439
|
// above), making a stranger's same-slug company invisible.
|
|
381
440
|
//
|
|
382
|
-
// A non-2xx already degrades to the global fallback below (
|
|
383
|
-
// A TRANSPORT failure must degrade the SAME way, not abort
|
|
384
|
-
// `resolveSlugInCallerNamespace`'s fetch is bounded by
|
|
385
|
-
// a vault gateway that accepts the connection but
|
|
386
|
-
// DOMException named `TimeoutError`.
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
try {
|
|
395
|
-
mine = await resolveSlugInCallerNamespace(token, ref);
|
|
441
|
+
// A non-2xx already degrades to the global fallback below (`miss` /
|
|
442
|
+
// `transient`). A TRANSPORT failure must degrade the SAME way, not abort
|
|
443
|
+
// the command: `resolveSlugInCallerNamespace`'s fetch is bounded by
|
|
444
|
+
// AbortSignal.timeout, so a vault gateway that accepts the connection but
|
|
445
|
+
// never answers rejects with a DOMException named `TimeoutError`. That is
|
|
446
|
+
// classified as `transient` (and retried once) inside the helper; a genuine
|
|
447
|
+
// hq-cli defect still throws, and the 401 AuthError that
|
|
448
|
+
// `raiseIfUnauthorized` throws still short-circuits before any fallback
|
|
449
|
+
// (Sentry HQ-CLI-1P / issue 7716090679).
|
|
450
|
+
const mine = await resolveSlugInCallerNamespace(token, ref);
|
|
451
|
+
if (mine.kind === "hit") {
|
|
452
|
+
return mine.uid;
|
|
396
453
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if (mine) {
|
|
404
|
-
return mine;
|
|
405
|
-
}
|
|
406
|
-
// The caller owns / belongs to no company with this slug. Fall back to the
|
|
407
|
-
// GLOBAL by-slug lookup for the cross-company / admin resolutions that
|
|
408
|
-
// intentionally target a company the caller isn't a member of (e.g. a
|
|
409
|
-
// group-grant target). A single global match resolves cleanly.
|
|
410
|
-
const res = await vaultApiFetch({
|
|
411
|
-
token,
|
|
412
|
-
path: `/entity/by-slug/company/${encodeURIComponent(ref)}`,
|
|
413
|
-
signal: AbortSignal.timeout(identityLookupTimeoutMs()),
|
|
414
|
-
});
|
|
454
|
+
// The caller owns / belongs to no company with this slug, or the namespace
|
|
455
|
+
// lookup itself failed. Fall back to the GLOBAL by-slug lookup for the
|
|
456
|
+
// cross-company / admin resolutions that intentionally target a company the
|
|
457
|
+
// caller isn't a member of (e.g. a group-grant target). A single global
|
|
458
|
+
// match resolves cleanly.
|
|
459
|
+
const res = await fetchGlobalCompanyBySlug(token, ref);
|
|
415
460
|
if (!res.ok) {
|
|
416
461
|
raiseIfUnauthorized(res);
|
|
417
462
|
const body = (await res.json().catch(() => ({})));
|
|
@@ -425,10 +470,19 @@ async function resolveCompanyUid(token, ref) {
|
|
|
425
470
|
`is in your namespace. Re-run with --company <uid> to pick one:\n` +
|
|
426
471
|
body.uids.map((u) => ` --company ${u}`).join('\n'));
|
|
427
472
|
}
|
|
428
|
-
|
|
473
|
+
// Only a clean namespace miss plus a global 404 is "this slug does not
|
|
474
|
+
// exist". A transient namespace failure followed by 404 is still a lookup
|
|
475
|
+
// failure — treating it as not-found sent owners to check a slug that
|
|
476
|
+
// was fine (Shepherd #2306).
|
|
477
|
+
if (res.status === 404 && mine.kind === "miss") {
|
|
429
478
|
throw Object.assign(new Error(`Company slug '${ref}' was not found. Check the slug or run \`hq companies list\`.`), { expected: true });
|
|
430
479
|
}
|
|
431
|
-
|
|
480
|
+
const reason = formatSlugLookupReason(res, body);
|
|
481
|
+
const retryable = mine.kind === "transient" || isTransientSlugLookupStatus(res.status);
|
|
482
|
+
const hint = retryable
|
|
483
|
+
? " This is a temporary lookup failure, not a missing company. Retry the command."
|
|
484
|
+
: "";
|
|
485
|
+
throw new Error(`Failed to resolve company slug '${ref}': ${reason}.${hint}`);
|
|
432
486
|
}
|
|
433
487
|
const data = (await res.json());
|
|
434
488
|
return data.entity.uid;
|