@indigoai-us/hq-cli 5.62.1 → 5.63.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/dist/commands/agents.d.ts +22 -1
- package/dist/commands/agents.js +98 -4
- package/dist/commands/billing.d.ts +17 -0
- package/dist/commands/billing.js +114 -0
- package/dist/commands/cloud.d.ts +11 -0
- package/dist/commands/cloud.js +40 -2
- package/dist/commands/members.d.ts +12 -1
- package/dist/commands/members.js +79 -12
- package/dist/commands/outposts.d.ts +18 -1
- package/dist/commands/outposts.js +91 -4
- package/dist/main.js +7 -2
- package/dist/utils/billing-gate.d.ts +77 -0
- package/dist/utils/billing-gate.js +127 -0
- package/package.json +2 -2
- package/src/commands/agents.test.ts +84 -0
- package/src/commands/agents.ts +157 -1
- package/src/commands/billing.test.ts +158 -0
- package/src/commands/billing.ts +146 -0
- package/src/commands/cloud.pull-all.test.ts +53 -0
- package/src/commands/cloud.ts +53 -0
- package/src/commands/members.test.ts +78 -0
- package/src/commands/members.ts +108 -11
- package/src/commands/outposts.test.ts +70 -0
- package/src/commands/outposts.ts +128 -2
- package/src/main.ts +6 -0
- package/src/utils/billing-gate.test.ts +95 -0
- package/src/utils/billing-gate.ts +182 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `hq billing` (billing.ts).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors company.test.ts: mock ensureCognitoToken + getCompanyUid, spy on
|
|
5
|
+
* global fetch, drive through a Commander program, assert the request shape and
|
|
6
|
+
* rendered output.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
import {
|
|
11
|
+
afterEach,
|
|
12
|
+
beforeEach,
|
|
13
|
+
describe,
|
|
14
|
+
expect,
|
|
15
|
+
it,
|
|
16
|
+
vi,
|
|
17
|
+
type MockInstance,
|
|
18
|
+
} from "vitest";
|
|
19
|
+
|
|
20
|
+
vi.mock("../utils/cognito-session.js", async (importOriginal) => {
|
|
21
|
+
const original =
|
|
22
|
+
await importOriginal<typeof import("../utils/cognito-session.js")>();
|
|
23
|
+
return {
|
|
24
|
+
...original,
|
|
25
|
+
ensureCognitoToken: vi.fn(async () => "test-token"),
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
vi.mock("../utils/vault-api.js", async (importOriginal) => {
|
|
30
|
+
const original = await importOriginal<typeof import("../utils/vault-api.js")>();
|
|
31
|
+
return {
|
|
32
|
+
...original,
|
|
33
|
+
getCompanyUid: vi.fn(async () => "cmp_acme"),
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
38
|
+
import { getCompanyUid } from "../utils/vault-api.js";
|
|
39
|
+
import { registerBillingCommand } from "./billing.js";
|
|
40
|
+
|
|
41
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
42
|
+
return new Response(JSON.stringify(body), {
|
|
43
|
+
status,
|
|
44
|
+
headers: { "Content-Type": "application/json" },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let fetchSpy: MockInstance<typeof fetch>;
|
|
49
|
+
let logSpy: MockInstance<typeof console.log>;
|
|
50
|
+
let stdoutSpy: MockInstance<typeof process.stdout.write>;
|
|
51
|
+
const mockEnsureCognitoToken = vi.mocked(ensureCognitoToken);
|
|
52
|
+
const mockGetCompanyUid = vi.mocked(getCompanyUid);
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.clearAllMocks();
|
|
56
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
57
|
+
mockEnsureCognitoToken.mockResolvedValue("test-token");
|
|
58
|
+
mockGetCompanyUid.mockResolvedValue("cmp_acme");
|
|
59
|
+
vi.spyOn(process, "exit").mockImplementation((code?: number) => {
|
|
60
|
+
throw new Error(`process.exit(${code})`);
|
|
61
|
+
});
|
|
62
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
63
|
+
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
64
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
vi.restoreAllMocks();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function buildProgram(): Command {
|
|
72
|
+
const program = new Command();
|
|
73
|
+
program.name("hq").exitOverride();
|
|
74
|
+
registerBillingCommand(program);
|
|
75
|
+
return program;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function run(args: string[]): Promise<void> {
|
|
79
|
+
await buildProgram().parseAsync(["node", "hq", ...args]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("hq billing status", () => {
|
|
83
|
+
it("GETs /v1/billing/summary and renders card-on-file", async () => {
|
|
84
|
+
fetchSpy.mockResolvedValueOnce(
|
|
85
|
+
jsonResponse(200, {
|
|
86
|
+
subscriptionStatus: "active",
|
|
87
|
+
defaultPaymentMethod: { present: true, brand: "visa", last4: "4242" },
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
await run(["billing", "--company", "acme", "status"]);
|
|
92
|
+
|
|
93
|
+
const url = String(fetchSpy.mock.calls[0][0]);
|
|
94
|
+
expect(url).toContain("/v1/billing/summary");
|
|
95
|
+
expect(url).toContain("companyUid=cmp_acme");
|
|
96
|
+
const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
97
|
+
expect(printed).toMatch(/active/);
|
|
98
|
+
expect(printed).toMatch(/visa/);
|
|
99
|
+
expect(printed).toMatch(/4242/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("flags no card on file", async () => {
|
|
103
|
+
fetchSpy.mockResolvedValueOnce(
|
|
104
|
+
jsonResponse(200, {
|
|
105
|
+
subscriptionStatus: null,
|
|
106
|
+
defaultPaymentMethod: { present: false, brand: null, last4: null },
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
await run(["billing", "--company", "acme", "status"]);
|
|
110
|
+
const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
111
|
+
expect(printed).toMatch(/no card on file/i);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("hq billing checkout", () => {
|
|
116
|
+
it("POSTs /v1/billing/checkout/org with companyUid and prints the url", async () => {
|
|
117
|
+
fetchSpy.mockResolvedValueOnce(
|
|
118
|
+
jsonResponse(200, { url: "https://checkout.stripe.com/abc" }),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
await run(["billing", "--company", "acme", "checkout"]);
|
|
122
|
+
|
|
123
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
124
|
+
expect(String(url)).toContain("/v1/billing/checkout/org");
|
|
125
|
+
expect(init?.method).toBe("POST");
|
|
126
|
+
expect(JSON.parse(init?.body as string)).toEqual({ companyUid: "cmp_acme" });
|
|
127
|
+
const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
128
|
+
expect(printed).toContain("https://checkout.stripe.com/abc");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("POSTs /v1/billing/checkout/person with --personal", async () => {
|
|
132
|
+
fetchSpy.mockResolvedValueOnce(
|
|
133
|
+
jsonResponse(200, { url: "https://checkout.stripe.com/xyz" }),
|
|
134
|
+
);
|
|
135
|
+
await run(["billing", "checkout", "--personal"]);
|
|
136
|
+
const [url, init] = fetchSpy.mock.calls[0];
|
|
137
|
+
expect(String(url)).toContain("/v1/billing/checkout/person");
|
|
138
|
+
expect(init?.method).toBe("POST");
|
|
139
|
+
// Person checkout resolves the payer from the JWT — no companyUid resolution.
|
|
140
|
+
expect(mockGetCompanyUid).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("emits raw JSON with --json", async () => {
|
|
144
|
+
fetchSpy.mockResolvedValueOnce(
|
|
145
|
+
jsonResponse(200, { url: "https://checkout.stripe.com/j" }),
|
|
146
|
+
);
|
|
147
|
+
await run(["billing", "--company", "acme", "checkout", "--json"]);
|
|
148
|
+
const printed = stdoutSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
149
|
+
expect(printed).toContain("https://checkout.stripe.com/j");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("exits 1 when the checkout response has no url", async () => {
|
|
153
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
154
|
+
await expect(
|
|
155
|
+
run(["billing", "--company", "acme", "checkout"]),
|
|
156
|
+
).rejects.toThrow("process.exit(1)");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq billing` — inspect billing state and mint a card-capture link from the
|
|
3
|
+
* terminal. Backs the paid-provisioning gate (agents & Outposts): a user can
|
|
4
|
+
* check whether a card is on file, and proactively hand out the same Stripe
|
|
5
|
+
* hosted card-capture link that a `402 billing_required` would surface.
|
|
6
|
+
*
|
|
7
|
+
* Targets the hq-pro billing control plane on `DEFAULT_VAULT_API_URL` via the
|
|
8
|
+
* shared `vaultApiFetch` helper — the same routes the console billing surfaces
|
|
9
|
+
* call.
|
|
10
|
+
*
|
|
11
|
+
* Subcommands:
|
|
12
|
+
* hq billing status [--company <slug>] — subscription + card on file
|
|
13
|
+
* hq billing checkout [--company <slug>] [--personal] — mint a card-capture link
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Command } from "commander";
|
|
17
|
+
import chalk from "chalk";
|
|
18
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
19
|
+
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
20
|
+
import { mintPaymentLink, type BillingSetupAction } from "../utils/billing-gate.js";
|
|
21
|
+
|
|
22
|
+
/** Shape of `GET /v1/billing/summary` we render. */
|
|
23
|
+
interface BillingSummary {
|
|
24
|
+
subscriptionStatus: string | null;
|
|
25
|
+
defaultPaymentMethod: {
|
|
26
|
+
present: boolean;
|
|
27
|
+
brand: string | null;
|
|
28
|
+
last4: string | null;
|
|
29
|
+
};
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fail(err: unknown): never {
|
|
34
|
+
console.error(
|
|
35
|
+
chalk.red("Error:"),
|
|
36
|
+
err instanceof Error ? err.message : String(err),
|
|
37
|
+
);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getBillingSummary(
|
|
42
|
+
token: string,
|
|
43
|
+
companyUid: string,
|
|
44
|
+
): Promise<BillingSummary> {
|
|
45
|
+
const res = await vaultApiFetch({
|
|
46
|
+
token,
|
|
47
|
+
path: "/v1/billing/summary",
|
|
48
|
+
query: { companyUid },
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
52
|
+
error?: string;
|
|
53
|
+
message?: string;
|
|
54
|
+
};
|
|
55
|
+
throw new Error(body.error ?? body.message ?? res.statusText);
|
|
56
|
+
}
|
|
57
|
+
return (await res.json()) as BillingSummary;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function registerBillingCommand(program: Command): void {
|
|
61
|
+
const billing = program
|
|
62
|
+
.command("billing")
|
|
63
|
+
.description("Inspect billing and mint a card-capture link")
|
|
64
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)");
|
|
65
|
+
|
|
66
|
+
const companyOf = (sub: Command): string | undefined =>
|
|
67
|
+
(sub.opts().company as string | undefined) ??
|
|
68
|
+
(billing.opts().company as string | undefined);
|
|
69
|
+
|
|
70
|
+
billing
|
|
71
|
+
.command("status")
|
|
72
|
+
.description("Show subscription status and whether a card is on file")
|
|
73
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
74
|
+
.option("--json", "Emit raw JSON")
|
|
75
|
+
.action(async function (this: Command, opts: { json?: boolean }) {
|
|
76
|
+
try {
|
|
77
|
+
const token = await ensureCognitoToken();
|
|
78
|
+
const companyUid = await getCompanyUid(token, companyOf(this));
|
|
79
|
+
const summary = await getBillingSummary(token, companyUid);
|
|
80
|
+
if (opts.json) {
|
|
81
|
+
process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const sub = summary.subscriptionStatus ?? "none";
|
|
85
|
+
console.log(`${chalk.bold("Subscription")}: ${sub}`);
|
|
86
|
+
const pm = summary.defaultPaymentMethod;
|
|
87
|
+
if (pm?.present) {
|
|
88
|
+
const detail =
|
|
89
|
+
pm.brand && pm.last4 ? `${pm.brand} ••••${pm.last4}` : "on file";
|
|
90
|
+
console.log(`${chalk.bold("Card")}: ${detail}`);
|
|
91
|
+
} else {
|
|
92
|
+
console.log(
|
|
93
|
+
`${chalk.bold("Card")}: ${chalk.yellow("no card on file")} — ` +
|
|
94
|
+
`run \`hq billing checkout\` to add one before provisioning paid resources.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
fail(err);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
billing
|
|
103
|
+
.command("checkout")
|
|
104
|
+
.description("Mint a Stripe card-capture link (shareable) to add a card")
|
|
105
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
106
|
+
.option(
|
|
107
|
+
"--personal",
|
|
108
|
+
"Mint the link for your personal payer (Outposts) instead of a company",
|
|
109
|
+
)
|
|
110
|
+
.option("--json", "Emit raw JSON")
|
|
111
|
+
.action(async function (
|
|
112
|
+
this: Command,
|
|
113
|
+
opts: { personal?: boolean; json?: boolean },
|
|
114
|
+
) {
|
|
115
|
+
try {
|
|
116
|
+
const token = await ensureCognitoToken();
|
|
117
|
+
let setup: BillingSetupAction;
|
|
118
|
+
if (opts.personal) {
|
|
119
|
+
setup = {
|
|
120
|
+
payerType: "person",
|
|
121
|
+
path: "/v1/billing/checkout/person",
|
|
122
|
+
method: "POST",
|
|
123
|
+
};
|
|
124
|
+
} else {
|
|
125
|
+
const companyUid = await getCompanyUid(token, companyOf(this));
|
|
126
|
+
setup = {
|
|
127
|
+
payerType: "company",
|
|
128
|
+
path: "/v1/billing/checkout/org",
|
|
129
|
+
method: "POST",
|
|
130
|
+
body: { companyUid },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const url = await mintPaymentLink(token, setup);
|
|
134
|
+
if (opts.json) {
|
|
135
|
+
process.stdout.write(JSON.stringify({ url }, null, 2) + "\n");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
console.log(
|
|
139
|
+
"Add a card here (safe to share with whoever owns billing):\n " +
|
|
140
|
+
chalk.cyan(url),
|
|
141
|
+
);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
fail(err);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
@@ -180,6 +180,59 @@ describe("pullAll", () => {
|
|
|
180
180
|
expect(companyCall?.prefixSet).toEqual(["knowledge/", "policies/"]);
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
+
// ── 1c. Sessions-exclusion: excludePrefixes is threaded into sync() ───────
|
|
184
|
+
//
|
|
185
|
+
// Guards the "session transcripts never sync DOWN" invariant on the CLI path:
|
|
186
|
+
// a company OWNER's STS scope is wide enough to read `sessions/` keys, so if
|
|
187
|
+
// the 4-site scope→sync-options mapping dropped `excludePrefixes` the CLI
|
|
188
|
+
// would bulk-download everyone's full-content session transcripts to local
|
|
189
|
+
// disk. This asserts the resolver's excludePrefixes reaches the SyncCallOptions
|
|
190
|
+
// handed to sync(); it FAILS if any mapping site drops the field.
|
|
191
|
+
it("threads the resolved excludePrefixes into sync() (sessions never pull DOWN)", async () => {
|
|
192
|
+
const vaultClient = makeVaultClient({
|
|
193
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
194
|
+
entitiesBySlug: { cmp_a: { slug: "acme" } },
|
|
195
|
+
});
|
|
196
|
+
const sync = makeSyncSpy();
|
|
197
|
+
// hq-cloud derives excludePrefixes on the resolved scope.
|
|
198
|
+
const resolveScope = vi.fn(async (_uid: string, _slug: string) => ({
|
|
199
|
+
syncMode: "shared" as const,
|
|
200
|
+
prefixSet: ["knowledge/", "policies/"],
|
|
201
|
+
excludePrefixes: ["sessions/"],
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
await pullAll(
|
|
205
|
+
{ hqRoot: "/tmp/hq" },
|
|
206
|
+
{ vaultClient, sync: sync.fn, resolveScope },
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const companyCall = sync.calls.find((c) => c.company === "cmp_a");
|
|
210
|
+
expect(companyCall?.excludePrefixes).toEqual(["sessions/"]);
|
|
211
|
+
// Sibling scope fields still flow — excludePrefixes rides alongside them.
|
|
212
|
+
expect(companyCall?.syncMode).toBe("shared");
|
|
213
|
+
expect(companyCall?.prefixSet).toEqual(["knowledge/", "policies/"]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("omits excludePrefixes when the resolved scope carries none", async () => {
|
|
217
|
+
const vaultClient = makeVaultClient({
|
|
218
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
219
|
+
entitiesBySlug: { cmp_a: { slug: "acme" } },
|
|
220
|
+
});
|
|
221
|
+
const sync = makeSyncSpy();
|
|
222
|
+
const resolveScope = vi.fn(async () => ({
|
|
223
|
+
syncMode: "shared" as const,
|
|
224
|
+
prefixSet: ["knowledge/"],
|
|
225
|
+
}));
|
|
226
|
+
|
|
227
|
+
await pullAll(
|
|
228
|
+
{ hqRoot: "/tmp/hq" },
|
|
229
|
+
{ vaultClient, sync: sync.fn, resolveScope },
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const companyCall = sync.calls.find((c) => c.company === "cmp_a");
|
|
233
|
+
expect(companyCall?.excludePrefixes).toBeUndefined();
|
|
234
|
+
});
|
|
235
|
+
|
|
183
236
|
it("passes no prefixSet for an all-mode membership (full pull preserved)", async () => {
|
|
184
237
|
const vaultClient = makeVaultClient({
|
|
185
238
|
memberships: [{ companyUid: "cmp_a" }],
|
package/src/commands/cloud.ts
CHANGED
|
@@ -153,6 +153,17 @@ export interface SyncCallOptions {
|
|
|
153
153
|
*/
|
|
154
154
|
syncMode?: SyncMode;
|
|
155
155
|
prefixSet?: string[];
|
|
156
|
+
/**
|
|
157
|
+
* Company-relative prefixes the pull must NOT materialize even when the
|
|
158
|
+
* caller's STS scope is wide enough to read them (sessions-exclusion /
|
|
159
|
+
* company-work-corpus). Derived by `resolvePullScope` in the hq-cloud release
|
|
160
|
+
* carrying the sessions/ pull-exclusion, forwarded to `sync()` here so the CLI
|
|
161
|
+
* honors the same "session transcripts never sync DOWN" invariant the
|
|
162
|
+
* background sync engine enforces. A company OWNER can read `sessions/` keys,
|
|
163
|
+
* so without this the CLI would bulk-download everyone's full-content session
|
|
164
|
+
* transcripts to local disk. Undefined/empty ⇒ nothing excluded (legacy).
|
|
165
|
+
*/
|
|
166
|
+
excludePrefixes?: string[];
|
|
156
167
|
/** Honor a `--force-scope-shrink` on a foreground pull (dirty files kept). */
|
|
157
168
|
forceScopeShrink?: boolean;
|
|
158
169
|
}
|
|
@@ -320,6 +331,29 @@ function pickCanonicalPerson<
|
|
|
320
331
|
})[0];
|
|
321
332
|
}
|
|
322
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Read the sessions-exclusion prefix set off a resolved {@link PullScope}.
|
|
336
|
+
*
|
|
337
|
+
* `resolvePullScope` in hq-cloud derives the sessions/ pull-exclusion and
|
|
338
|
+
* derives `excludePrefixes` (e.g. `["sessions/"]`) so a company OWNER — whose STS
|
|
339
|
+
* scope is wide enough to read `sessions/` keys — does not bulk-download
|
|
340
|
+
* everyone's full-content session transcripts to local disk, upholding the
|
|
341
|
+
* "session transcripts never sync DOWN" invariant hq-cloud enforces for the
|
|
342
|
+
* background engine. Keep the small runtime shape check even though PullScope
|
|
343
|
+
* now declares the field: it protects the CLI when an older external resolver
|
|
344
|
+
* implementation is injected by tests or downstream callers.
|
|
345
|
+
*/
|
|
346
|
+
function readScopeExcludePrefixes(
|
|
347
|
+
scope: PullScope | undefined,
|
|
348
|
+
): string[] | undefined {
|
|
349
|
+
const raw: unknown = scope?.excludePrefixes;
|
|
350
|
+
if (!Array.isArray(raw)) return undefined;
|
|
351
|
+
const prefixes = raw.filter(
|
|
352
|
+
(p): p is string => typeof p === "string" && p.length > 0,
|
|
353
|
+
);
|
|
354
|
+
return prefixes.length > 0 ? prefixes : undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
323
357
|
export async function pullAll(
|
|
324
358
|
options: PullAllOptions,
|
|
325
359
|
deps: PullAllDeps,
|
|
@@ -398,6 +432,12 @@ export async function pullAll(
|
|
|
398
432
|
if (scope.prefixSet !== undefined) {
|
|
399
433
|
entry.syncOptions.prefixSet = scope.prefixSet;
|
|
400
434
|
}
|
|
435
|
+
// Sessions-exclusion: thread the resolver's excludePrefixes into the
|
|
436
|
+
// pull so a wide-STS OWNER never bulk-downloads session transcripts.
|
|
437
|
+
const excludePrefixes = readScopeExcludePrefixes(scope);
|
|
438
|
+
if (excludePrefixes) {
|
|
439
|
+
entry.syncOptions.excludePrefixes = excludePrefixes;
|
|
440
|
+
}
|
|
401
441
|
} catch {
|
|
402
442
|
resolvedMode = undefined;
|
|
403
443
|
}
|
|
@@ -1205,6 +1245,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1205
1245
|
process.exit(1);
|
|
1206
1246
|
}
|
|
1207
1247
|
|
|
1248
|
+
const excludePrefixes = readScopeExcludePrefixes(pullScope);
|
|
1208
1249
|
const result = await sync({
|
|
1209
1250
|
company: options.company,
|
|
1210
1251
|
onConflict: options.onConflict,
|
|
@@ -1216,6 +1257,9 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1216
1257
|
...(pullScope?.prefixSet !== undefined
|
|
1217
1258
|
? { prefixSet: pullScope.prefixSet }
|
|
1218
1259
|
: {}),
|
|
1260
|
+
// Sessions-exclusion: never materialize excluded prefixes even for
|
|
1261
|
+
// a wide-STS owner.
|
|
1262
|
+
...(excludePrefixes ? { excludePrefixes } : {}),
|
|
1219
1263
|
...(options.forceScopeShrink ? { forceScopeShrink: true } : {}),
|
|
1220
1264
|
});
|
|
1221
1265
|
|
|
@@ -1519,6 +1563,11 @@ async function runPullAll(
|
|
|
1519
1563
|
: {}),
|
|
1520
1564
|
...(opts.syncMode !== undefined ? { syncMode: opts.syncMode } : {}),
|
|
1521
1565
|
...(opts.prefixSet !== undefined ? { prefixSet: opts.prefixSet } : {}),
|
|
1566
|
+
// Sessions-exclusion: forward the per-company excludePrefixes that
|
|
1567
|
+
// pullAll stamped onto SyncCallOptions into the real hq-cloud sync().
|
|
1568
|
+
...(opts.excludePrefixes?.length
|
|
1569
|
+
? { excludePrefixes: opts.excludePrefixes }
|
|
1570
|
+
: {}),
|
|
1522
1571
|
...(opts.forceScopeShrink ? { forceScopeShrink: true } : {}),
|
|
1523
1572
|
}),
|
|
1524
1573
|
},
|
|
@@ -1877,6 +1926,7 @@ async function runNowSingle(
|
|
|
1877
1926
|
}
|
|
1878
1927
|
|
|
1879
1928
|
console.log(chalk.dim(" → pull leg"));
|
|
1929
|
+
const excludePrefixes = readScopeExcludePrefixes(pullScope);
|
|
1880
1930
|
const pullResult = await sync({
|
|
1881
1931
|
company: targetCompany,
|
|
1882
1932
|
vaultConfig,
|
|
@@ -1890,6 +1940,9 @@ async function runNowSingle(
|
|
|
1890
1940
|
...(pullScope?.prefixSet !== undefined
|
|
1891
1941
|
? { prefixSet: pullScope.prefixSet }
|
|
1892
1942
|
: {}),
|
|
1943
|
+
// Sessions-exclusion: honor the resolver's excludePrefixes on the
|
|
1944
|
+
// `hq sync now` pull leg too.
|
|
1945
|
+
...(excludePrefixes ? { excludePrefixes } : {}),
|
|
1893
1946
|
...(forceScopeShrink ? { forceScopeShrink: true } : {}),
|
|
1894
1947
|
});
|
|
1895
1948
|
console.log(
|
|
@@ -95,6 +95,24 @@ describe("detectTarget", () => {
|
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
+
it("recognizes fleet agent uids", () => {
|
|
99
|
+
expect(detectTarget("agt_01HXYZABCDEFGHJKMNPQRSTVWX")).toEqual({
|
|
100
|
+
type: "agent",
|
|
101
|
+
value: "agt_01HXYZABCDEFGHJKMNPQRSTVWX",
|
|
102
|
+
isAgent: true,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("recognizes fleet agent machine emails", () => {
|
|
107
|
+
expect(
|
|
108
|
+
detectTarget("agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai"),
|
|
109
|
+
).toEqual({
|
|
110
|
+
type: "email",
|
|
111
|
+
value: "agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai",
|
|
112
|
+
isAgent: true,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
98
116
|
it("returns null for invalid targets", () => {
|
|
99
117
|
expect(detectTarget("not-a-target")).toBeNull();
|
|
100
118
|
expect(detectTarget("cmp_company")).toBeNull();
|
|
@@ -234,6 +252,48 @@ describe("inviteMember", () => {
|
|
|
234
252
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
235
253
|
});
|
|
236
254
|
|
|
255
|
+
it("invites a fleet agent by uid as member via /membership/invite", async () => {
|
|
256
|
+
fetchSpy.mockResolvedValueOnce(
|
|
257
|
+
jsonResponse(201, {
|
|
258
|
+
membership: {
|
|
259
|
+
membershipKey: "agt_01HXYZ#cmp_acme",
|
|
260
|
+
role: "member",
|
|
261
|
+
status: "active",
|
|
262
|
+
},
|
|
263
|
+
agentUid: "agt_01HXYZABCDEFGHJKMNPQRSTVWX",
|
|
264
|
+
hostCompanyUid: "cmp_host",
|
|
265
|
+
activationBilled: false,
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const result = await inviteMember({
|
|
270
|
+
target: "agt_01HXYZABCDEFGHJKMNPQRSTVWX",
|
|
271
|
+
role: "member",
|
|
272
|
+
companyUid: "cmp_acme",
|
|
273
|
+
callerUid: "prs_admin",
|
|
274
|
+
token: "test-token",
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(result.membership.status).toBe("active");
|
|
278
|
+
expect(result.membership.role).toBe("member");
|
|
279
|
+
const body = JSON.parse((fetchSpy.mock.calls[0][1]?.body as string) ?? "{}");
|
|
280
|
+
expect(body.personUid).toBe("agt_01HXYZABCDEFGHJKMNPQRSTVWX");
|
|
281
|
+
expect(body.role).toBe("member");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("rejects owner/guest roles for agent invites", async () => {
|
|
285
|
+
await expect(
|
|
286
|
+
inviteMember({
|
|
287
|
+
target: "agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai",
|
|
288
|
+
role: "owner",
|
|
289
|
+
companyUid: "cmp_acme",
|
|
290
|
+
callerUid: "prs_admin",
|
|
291
|
+
token: "test-token",
|
|
292
|
+
}),
|
|
293
|
+
).rejects.toThrow(/member or admin/);
|
|
294
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
295
|
+
});
|
|
296
|
+
|
|
237
297
|
it("rejects an unknown role", async () => {
|
|
238
298
|
await expect(
|
|
239
299
|
inviteMember({
|
|
@@ -1134,6 +1194,24 @@ describe("registerMembersCommand promote", () => {
|
|
|
1134
1194
|
// ---------------------------------------------------------------------------
|
|
1135
1195
|
|
|
1136
1196
|
describe("resolveRevokeTargetToMembershipKey", () => {
|
|
1197
|
+
it("wraps fleet agent uids as agt_…#companyUid", () => {
|
|
1198
|
+
expect(
|
|
1199
|
+
resolveRevokeTargetToMembershipKey(
|
|
1200
|
+
"agt_01HXYZABCDEFGHJKMNPQRSTVWX",
|
|
1201
|
+
"cmp_abc",
|
|
1202
|
+
),
|
|
1203
|
+
).toBe("agt_01HXYZABCDEFGHJKMNPQRSTVWX#cmp_abc");
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
it("maps agent machine emails to agt_…#companyUid (active guest memberships)", () => {
|
|
1207
|
+
expect(
|
|
1208
|
+
resolveRevokeTargetToMembershipKey(
|
|
1209
|
+
"agt-01hxyzabcdefghjkmnpqrstvwx@agents.getindigo.ai",
|
|
1210
|
+
"cmp_abc",
|
|
1211
|
+
),
|
|
1212
|
+
).toBe("agt_01HXYZABCDEFGHJKMNPQRSTVWX#cmp_abc");
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1137
1215
|
// Regression: `hq members revoke alice@example.com` used to send the raw
|
|
1138
1216
|
// email straight to /membership/revoke, which the server rejects with 404
|
|
1139
1217
|
// "Invite not found" because it keys on `email:<email>#<companyUid>`. Live
|