@indigoai-us/hq-cli 5.68.2 → 5.69.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 +13 -0
- package/dist/commands/outposts.d.ts +16 -1
- package/dist/commands/outposts.js +230 -3
- package/dist/main.js +6 -2
- package/dist/utils/cli-telemetry.d.ts +6 -0
- package/dist/utils/cli-telemetry.js +60 -0
- package/dist/utils/cognito-session.d.ts +2 -1
- package/dist/utils/cognito-session.js +5 -2
- package/dist/utils/vault-api.d.ts +1 -0
- package/dist/utils/vault-api.js +3 -2
- package/package.json +1 -1
- package/src/commands/outposts-self-deploy.test.ts +243 -0
- package/src/commands/outposts.ts +346 -1
- package/src/main.ts +5 -0
- package/src/utils/cli-telemetry.test.ts +153 -0
- package/src/utils/cli-telemetry.ts +61 -0
- package/src/utils/cognito-session.ts +4 -0
- package/src/utils/vault-api.test.ts +18 -0
- package/src/utils/vault-api.ts +2 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Each case does vi.resetModules() + a dynamic import() to reset the module-scoped
|
|
4
|
+
// once-per-process memoization; the cold transform/import is slow, so raise the
|
|
5
|
+
// per-test timeout well above the default 5s.
|
|
6
|
+
vi.setConfig({ testTimeout: 30_000 });
|
|
7
|
+
|
|
8
|
+
const mocks = vi.hoisted(() => ({
|
|
9
|
+
browserLogin: vi.fn(),
|
|
10
|
+
isExpiring: vi.fn(),
|
|
11
|
+
isMachineIdentity: vi.fn(),
|
|
12
|
+
loadCachedTokens: vi.fn(),
|
|
13
|
+
refreshTokens: vi.fn(),
|
|
14
|
+
vaultApiFetch: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("@indigoai-us/hq-cloud", async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal<typeof import("@indigoai-us/hq-cloud")>();
|
|
19
|
+
return { ...actual, ...mocks };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
vi.mock("./vault-api.js", () => ({
|
|
23
|
+
vaultApiFetch: mocks.vaultApiFetch,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("../cli-version.js", () => ({
|
|
27
|
+
CLI_NAME: "@indigoai-us/hq-cli",
|
|
28
|
+
CLI_VERSION: "5.68.1",
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const HUMAN_TOKENS = {
|
|
32
|
+
accessToken: "human-access-token",
|
|
33
|
+
idToken: "human-id-token",
|
|
34
|
+
refreshToken: "human-refresh-token",
|
|
35
|
+
expiresAt: Date.now() + 3_600_000,
|
|
36
|
+
tokenType: "Bearer" as const,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const MACHINE_TOKENS = {
|
|
40
|
+
...HUMAN_TOKENS,
|
|
41
|
+
accessToken: "machine-access-token",
|
|
42
|
+
idToken: "machine-id-token",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
async function loadEmitter() {
|
|
46
|
+
vi.resetModules();
|
|
47
|
+
return import("./cli-telemetry.js");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.resetAllMocks();
|
|
52
|
+
mocks.isMachineIdentity.mockReturnValue(false);
|
|
53
|
+
mocks.isExpiring.mockReturnValue(false);
|
|
54
|
+
mocks.vaultApiFetch.mockResolvedValue(new Response(null, { status: 202 }));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.useRealTimers();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("emitCliSessionStarted", () => {
|
|
62
|
+
it("posts the privacy-safe event with a valid cached human token", async () => {
|
|
63
|
+
mocks.loadCachedTokens.mockReturnValue(HUMAN_TOKENS);
|
|
64
|
+
const { emitCliSessionStarted } = await loadEmitter();
|
|
65
|
+
|
|
66
|
+
await emitCliSessionStarted();
|
|
67
|
+
|
|
68
|
+
expect(mocks.vaultApiFetch).toHaveBeenCalledTimes(1);
|
|
69
|
+
const request = mocks.vaultApiFetch.mock.calls[0]?.[0];
|
|
70
|
+
expect(request).toMatchObject({
|
|
71
|
+
token: HUMAN_TOKENS.accessToken,
|
|
72
|
+
path: "/v1/telemetry/events",
|
|
73
|
+
method: "POST",
|
|
74
|
+
signal: expect.any(AbortSignal),
|
|
75
|
+
});
|
|
76
|
+
expect(request.body.events).toHaveLength(1);
|
|
77
|
+
expect(request.body.events[0]).toEqual({
|
|
78
|
+
eventName: "cli_session_started",
|
|
79
|
+
app: "hq-cli",
|
|
80
|
+
source: "cli",
|
|
81
|
+
occurredAt: expect.any(String),
|
|
82
|
+
schemaVersion: 1,
|
|
83
|
+
properties: { version: "5.68.1" },
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("uses the cached ID token for a machine identity", async () => {
|
|
88
|
+
mocks.isMachineIdentity.mockReturnValue(true);
|
|
89
|
+
mocks.loadCachedTokens.mockReturnValue(MACHINE_TOKENS);
|
|
90
|
+
const { emitCliSessionStarted } = await loadEmitter();
|
|
91
|
+
|
|
92
|
+
await emitCliSessionStarted();
|
|
93
|
+
|
|
94
|
+
expect(mocks.vaultApiFetch).toHaveBeenCalledWith(
|
|
95
|
+
expect.objectContaining({ token: MACHINE_TOKENS.idToken }),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it.each([
|
|
100
|
+
["is absent", null, false],
|
|
101
|
+
["is expiring", HUMAN_TOKENS, true],
|
|
102
|
+
])(
|
|
103
|
+
"does not emit or refresh/login when the cached token %s",
|
|
104
|
+
async (_description, cachedTokens, expiring) => {
|
|
105
|
+
mocks.loadCachedTokens.mockReturnValue(cachedTokens);
|
|
106
|
+
mocks.isExpiring.mockReturnValue(expiring);
|
|
107
|
+
const { emitCliSessionStarted } = await loadEmitter();
|
|
108
|
+
|
|
109
|
+
await emitCliSessionStarted();
|
|
110
|
+
|
|
111
|
+
expect(mocks.vaultApiFetch).not.toHaveBeenCalled();
|
|
112
|
+
expect(mocks.refreshTokens).not.toHaveBeenCalled();
|
|
113
|
+
expect(mocks.browserLogin).not.toHaveBeenCalled();
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
it("swallows a non-2xx telemetry response", async () => {
|
|
118
|
+
mocks.loadCachedTokens.mockReturnValue(HUMAN_TOKENS);
|
|
119
|
+
mocks.vaultApiFetch.mockResolvedValue(new Response(null, { status: 503 }));
|
|
120
|
+
const { emitCliSessionStarted } = await loadEmitter();
|
|
121
|
+
|
|
122
|
+
await expect(emitCliSessionStarted()).resolves.toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("aborts a slow telemetry request without failing the command", async () => {
|
|
126
|
+
vi.useFakeTimers();
|
|
127
|
+
mocks.loadCachedTokens.mockReturnValue(HUMAN_TOKENS);
|
|
128
|
+
mocks.vaultApiFetch.mockImplementation(
|
|
129
|
+
({ signal }: { signal: AbortSignal }) =>
|
|
130
|
+
new Promise((_resolve, reject) => {
|
|
131
|
+
signal.addEventListener("abort", () => reject(new Error("aborted")), {
|
|
132
|
+
once: true,
|
|
133
|
+
});
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
const { emitCliSessionStarted } = await loadEmitter();
|
|
137
|
+
|
|
138
|
+
const emission = emitCliSessionStarted();
|
|
139
|
+
await vi.advanceTimersByTimeAsync(1_200);
|
|
140
|
+
|
|
141
|
+
await expect(emission).resolves.toBeUndefined();
|
|
142
|
+
expect(mocks.vaultApiFetch.mock.calls[0]?.[0].signal.aborted).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("posts at most once when the root hook is invoked repeatedly", async () => {
|
|
146
|
+
mocks.loadCachedTokens.mockReturnValue(HUMAN_TOKENS);
|
|
147
|
+
const { emitCliSessionStarted } = await loadEmitter();
|
|
148
|
+
|
|
149
|
+
await Promise.all([emitCliSessionStarted(), emitCliSessionStarted()]);
|
|
150
|
+
|
|
151
|
+
expect(mocks.vaultApiFetch).toHaveBeenCalledTimes(1);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { CLI_VERSION } from "../cli-version.js";
|
|
2
|
+
import {
|
|
3
|
+
isExpiring,
|
|
4
|
+
isMachineIdentity,
|
|
5
|
+
loadCachedTokens,
|
|
6
|
+
} from "./cognito-session.js";
|
|
7
|
+
import { vaultApiFetch } from "./vault-api.js";
|
|
8
|
+
|
|
9
|
+
const TELEMETRY_TIMEOUT_MS = 1_200;
|
|
10
|
+
|
|
11
|
+
let cliSessionStartedPromise: Promise<void> | undefined;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Emit the authenticated CLI session signal using an already-cached token.
|
|
15
|
+
* This deliberately never refreshes a session or opens a browser.
|
|
16
|
+
*/
|
|
17
|
+
export function emitCliSessionStarted(): Promise<void> {
|
|
18
|
+
cliSessionStartedPromise ??= emitCachedCliSessionStarted();
|
|
19
|
+
return cliSessionStartedPromise;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function emitCachedCliSessionStarted(): Promise<void> {
|
|
23
|
+
try {
|
|
24
|
+
const cached = loadCachedTokens();
|
|
25
|
+
if (!cached || isExpiring(cached, 120)) return;
|
|
26
|
+
|
|
27
|
+
const token = isMachineIdentity() ? cached.idToken : cached.accessToken;
|
|
28
|
+
if (!token) return;
|
|
29
|
+
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const timeout = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const response = await vaultApiFetch({
|
|
35
|
+
token,
|
|
36
|
+
path: "/v1/telemetry/events",
|
|
37
|
+
method: "POST",
|
|
38
|
+
body: {
|
|
39
|
+
events: [
|
|
40
|
+
{
|
|
41
|
+
eventName: "cli_session_started",
|
|
42
|
+
app: "hq-cli",
|
|
43
|
+
source: "cli",
|
|
44
|
+
occurredAt: new Date().toISOString(),
|
|
45
|
+
schemaVersion: 1,
|
|
46
|
+
properties: { version: CLI_VERSION },
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
signal: controller.signal,
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok) return;
|
|
53
|
+
} catch {
|
|
54
|
+
// Telemetry is best effort and must never affect a CLI command.
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timeout);
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Cached-token reads are also best effort (for example, a malformed cache).
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -38,6 +38,10 @@ import {
|
|
|
38
38
|
} from "@indigoai-us/hq-cloud";
|
|
39
39
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
40
40
|
|
|
41
|
+
// Re-export the cached-session primitives for callers that must deliberately
|
|
42
|
+
// avoid the refresh/login behavior in the higher-level helpers below.
|
|
43
|
+
export { isExpiring, isMachineIdentity, loadCachedTokens };
|
|
44
|
+
|
|
41
45
|
export const DEFAULT_COGNITO: CognitoAuthConfig = {
|
|
42
46
|
region: process.env.AWS_REGION ?? "us-east-1",
|
|
43
47
|
userPoolDomain: process.env.HQ_COGNITO_DOMAIN ?? "vault-indigo-hq-prod",
|
|
@@ -229,3 +229,21 @@ describe('vaultApiFetch breadcrumb URL sanitization', () => {
|
|
|
229
229
|
expect(errorCrumb.data?.url).toContain('?<redacted>');
|
|
230
230
|
});
|
|
231
231
|
});
|
|
232
|
+
|
|
233
|
+
describe('vaultApiFetch abort signals', () => {
|
|
234
|
+
it('passes an optional abort signal to fetch', async () => {
|
|
235
|
+
fetchMock.mockResolvedValueOnce(mockResponse(200, {}));
|
|
236
|
+
const controller = new AbortController();
|
|
237
|
+
|
|
238
|
+
await vaultApiFetch({
|
|
239
|
+
token: 'tok',
|
|
240
|
+
path: '/v1/telemetry/events',
|
|
241
|
+
signal: controller.signal,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
245
|
+
expect.any(String),
|
|
246
|
+
expect.objectContaining({ signal: controller.signal }),
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
});
|
package/src/utils/vault-api.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface VaultApiOptions {
|
|
|
7
7
|
method?: string;
|
|
8
8
|
body?: Record<string, unknown>;
|
|
9
9
|
query?: Record<string, string>;
|
|
10
|
+
signal?: AbortSignal;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export async function vaultApiFetch(opts: VaultApiOptions): Promise<Response> {
|
|
@@ -31,6 +32,7 @@ export async function vaultApiFetch(opts: VaultApiOptions): Promise<Response> {
|
|
|
31
32
|
'Content-Type': 'application/json',
|
|
32
33
|
},
|
|
33
34
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
35
|
+
signal: opts.signal,
|
|
34
36
|
});
|
|
35
37
|
if (!response.ok) {
|
|
36
38
|
Sentry.addBreadcrumb({
|