@indigoai-us/hq-cli 5.57.0 → 5.58.1
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/people.d.ts +12 -8
- package/dist/commands/people.js +87 -41
- package/dist/commands/secrets.js +26 -33
- package/dist/utils/sandbox-runner-client.d.ts +4 -9
- package/dist/utils/sandbox-runner-client.js +4 -11
- package/package.json +1 -1
- package/src/commands/people.test.ts +217 -52
- package/src/commands/people.ts +114 -64
- package/src/commands/secrets.test.ts +84 -78
- package/src/commands/secrets.ts +26 -32
- package/src/utils/sandbox-runner-client.test.ts +21 -23
- package/src/utils/sandbox-runner-client.ts +6 -19
package/src/commands/people.ts
CHANGED
|
@@ -6,25 +6,21 @@
|
|
|
6
6
|
* hq people search <keyword> [--company <slug>] [--json]
|
|
7
7
|
* hq people resolve <name> [--company <slug>] [--json]
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Local records from `companies/<company>/people/<person>/meta.yaml` are the
|
|
10
|
+
* curated primary source. On misses, commands may fall back to the membership
|
|
11
|
+
* roster for exactly ONE company (the active company, or the one named by
|
|
12
|
+
* `--company`); nothing reads across company boundaries.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import * as fs from "fs";
|
|
15
16
|
import { Command, Option } from "commander";
|
|
16
17
|
import chalk from "chalk";
|
|
17
|
-
import { VaultClient } from "@indigoai-us/hq-cloud";
|
|
18
18
|
import * as yaml from "js-yaml";
|
|
19
19
|
import { findHqRoot } from "../utils/manifest.js";
|
|
20
20
|
import { manifestPath, type ManifestDoc } from "./cloud-provision.js";
|
|
21
|
-
import {
|
|
22
|
-
DEFAULT_COGNITO,
|
|
23
|
-
buildVaultConfig,
|
|
24
|
-
ensureCognitoToken,
|
|
25
|
-
} from "../utils/cognito-session.js";
|
|
21
|
+
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
26
22
|
import { getCompanyUid } from "../utils/vault-api.js";
|
|
27
|
-
import {
|
|
23
|
+
import { listActiveMembers, type ActiveMember } from "./members.js";
|
|
28
24
|
import {
|
|
29
25
|
assertSafeCompanySlug,
|
|
30
26
|
listCompanyPeople,
|
|
@@ -39,13 +35,13 @@ interface PeopleScopeOpts {
|
|
|
39
35
|
hqRoot?: string;
|
|
40
36
|
}
|
|
41
37
|
|
|
42
|
-
export type
|
|
38
|
+
export type FetchPeopleRoster = (
|
|
43
39
|
hqRoot: string,
|
|
44
40
|
companySlug: string,
|
|
45
|
-
) => Promise<
|
|
41
|
+
) => Promise<PersonRecord[]>;
|
|
46
42
|
|
|
47
43
|
interface PeopleCommandDeps {
|
|
48
|
-
|
|
44
|
+
fetchRoster?: FetchPeopleRoster;
|
|
49
45
|
}
|
|
50
46
|
|
|
51
47
|
interface PeopleLookupOpts {
|
|
@@ -53,23 +49,71 @@ interface PeopleLookupOpts {
|
|
|
53
49
|
json?: boolean;
|
|
54
50
|
}
|
|
55
51
|
|
|
56
|
-
|
|
52
|
+
function slugify(value: string): string {
|
|
53
|
+
return value
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
56
|
+
.replace(/-+/g, "-")
|
|
57
|
+
.replace(/^-|-$/g, "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function activeMemberToPersonRecord(
|
|
61
|
+
m: ActiveMember,
|
|
62
|
+
): PersonRecord | null {
|
|
63
|
+
const name =
|
|
64
|
+
m.personName?.trim() ||
|
|
65
|
+
m.personEmail?.trim() ||
|
|
66
|
+
m.personSlug?.trim() ||
|
|
67
|
+
"";
|
|
68
|
+
if (!name) return null;
|
|
69
|
+
|
|
70
|
+
const email = m.personEmail?.trim() || undefined;
|
|
71
|
+
const slug = m.personSlug?.trim() || slugify(name) || m.personUid;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
slug,
|
|
75
|
+
name,
|
|
76
|
+
email,
|
|
77
|
+
role: m.role || undefined,
|
|
78
|
+
type: "internal",
|
|
79
|
+
source: `hq-pro membership: /membership/company/${m.companyUid}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function fetchMembershipRoster(
|
|
57
84
|
hqRoot: string,
|
|
58
85
|
companySlug: string,
|
|
59
|
-
): Promise<
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
await getCompanyUid(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
86
|
+
): Promise<PersonRecord[]> {
|
|
87
|
+
void hqRoot;
|
|
88
|
+
const token = await ensureCognitoToken();
|
|
89
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
90
|
+
const members = await listActiveMembers(token, companyUid);
|
|
91
|
+
return members
|
|
92
|
+
.map(activeMemberToPersonRecord)
|
|
93
|
+
.filter((r): r is PersonRecord => r !== null);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function personIdentityKey(person: PersonRecord): string {
|
|
97
|
+
return (
|
|
98
|
+
person.email?.toLowerCase() ||
|
|
99
|
+
person.slug?.toLowerCase() ||
|
|
100
|
+
person.name.toLowerCase()
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function mergePeople(
|
|
105
|
+
primary: PersonRecord[],
|
|
106
|
+
extra: PersonRecord[],
|
|
107
|
+
): PersonRecord[] {
|
|
108
|
+
const seen = new Set(primary.map(personIdentityKey));
|
|
109
|
+
const merged = [...primary];
|
|
110
|
+
for (const person of extra) {
|
|
111
|
+
const key = personIdentityKey(person);
|
|
112
|
+
if (seen.has(key)) continue;
|
|
113
|
+
seen.add(key);
|
|
114
|
+
merged.push(person);
|
|
115
|
+
}
|
|
116
|
+
return merged;
|
|
73
117
|
}
|
|
74
118
|
|
|
75
119
|
/** Companies that still exist (anything not explicitly `status: archived`). */
|
|
@@ -159,26 +203,25 @@ function fail(message: string): never {
|
|
|
159
203
|
process.exit(1);
|
|
160
204
|
}
|
|
161
205
|
|
|
162
|
-
function
|
|
206
|
+
function logRosterFetchFailure(companySlug: string, err: unknown): void {
|
|
163
207
|
const message = err instanceof Error ? err.message : String(err);
|
|
164
208
|
console.error(
|
|
165
209
|
chalk.dim(
|
|
166
|
-
` Could not
|
|
210
|
+
` Could not fetch people roster for '${companySlug}': ${message}`,
|
|
167
211
|
),
|
|
168
212
|
);
|
|
169
213
|
}
|
|
170
214
|
|
|
171
|
-
async function
|
|
172
|
-
|
|
215
|
+
async function tryFetchRoster(
|
|
216
|
+
fetchRoster: FetchPeopleRoster,
|
|
173
217
|
hqRoot: string,
|
|
174
218
|
slug: string,
|
|
175
|
-
): Promise<
|
|
219
|
+
): Promise<PersonRecord[] | null> {
|
|
176
220
|
try {
|
|
177
|
-
await
|
|
178
|
-
return true;
|
|
221
|
+
return await fetchRoster(hqRoot, slug);
|
|
179
222
|
} catch (err) {
|
|
180
|
-
|
|
181
|
-
return
|
|
223
|
+
logRosterFetchFailure(slug, err);
|
|
224
|
+
return null;
|
|
182
225
|
}
|
|
183
226
|
}
|
|
184
227
|
|
|
@@ -188,25 +231,20 @@ export async function resolvePersonWithRosterFallback(
|
|
|
188
231
|
slug: string;
|
|
189
232
|
name: string;
|
|
190
233
|
opts?: PeopleLookupOpts;
|
|
191
|
-
|
|
234
|
+
fetchRoster?: FetchPeopleRoster;
|
|
192
235
|
},
|
|
193
236
|
): Promise<ReturnType<typeof resolveNameToEmail>> {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
input.name,
|
|
197
|
-
);
|
|
237
|
+
const localPeople = listCompanyPeople(input.hqRoot, input.slug);
|
|
238
|
+
const local = resolveNameToEmail(localPeople, input.name);
|
|
198
239
|
if (local.status !== "not_found" || input.opts?.localOnly) return local;
|
|
199
240
|
|
|
200
|
-
const
|
|
201
|
-
input.
|
|
241
|
+
const roster = await tryFetchRoster(
|
|
242
|
+
input.fetchRoster ?? fetchMembershipRoster,
|
|
202
243
|
input.hqRoot,
|
|
203
244
|
input.slug,
|
|
204
245
|
);
|
|
205
|
-
if (!
|
|
206
|
-
return resolveNameToEmail(
|
|
207
|
-
listCompanyPeople(input.hqRoot, input.slug),
|
|
208
|
-
input.name,
|
|
209
|
-
);
|
|
246
|
+
if (!roster) return local;
|
|
247
|
+
return resolveNameToEmail(mergePeople(localPeople, roster), input.name);
|
|
210
248
|
}
|
|
211
249
|
|
|
212
250
|
export async function searchPeopleWithRosterFallback(input: {
|
|
@@ -214,21 +252,19 @@ export async function searchPeopleWithRosterFallback(input: {
|
|
|
214
252
|
slug: string;
|
|
215
253
|
keyword: string;
|
|
216
254
|
opts?: PeopleLookupOpts;
|
|
217
|
-
|
|
255
|
+
fetchRoster?: FetchPeopleRoster;
|
|
218
256
|
}): Promise<PersonRecord[]> {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
input.keyword,
|
|
222
|
-
);
|
|
257
|
+
const localPeople = listCompanyPeople(input.hqRoot, input.slug);
|
|
258
|
+
const local = searchPeople(localPeople, input.keyword);
|
|
223
259
|
if (local.length > 0 || input.opts?.localOnly) return local;
|
|
224
260
|
|
|
225
|
-
const
|
|
226
|
-
input.
|
|
261
|
+
const roster = await tryFetchRoster(
|
|
262
|
+
input.fetchRoster ?? fetchMembershipRoster,
|
|
227
263
|
input.hqRoot,
|
|
228
264
|
input.slug,
|
|
229
265
|
);
|
|
230
|
-
if (!
|
|
231
|
-
return searchPeople(
|
|
266
|
+
if (!roster) return local;
|
|
267
|
+
return searchPeople(mergePeople(localPeople, roster), input.keyword);
|
|
232
268
|
}
|
|
233
269
|
|
|
234
270
|
export function registerPeopleCommand(
|
|
@@ -257,12 +293,26 @@ export function registerPeopleCommand(
|
|
|
257
293
|
.command("list")
|
|
258
294
|
.description("List all people recorded for the company")
|
|
259
295
|
.option("--json", "Output JSON instead of a table")
|
|
260
|
-
.
|
|
296
|
+
.option(
|
|
297
|
+
"--local-only",
|
|
298
|
+
"Skip network fallback; list only the local people roster",
|
|
299
|
+
)
|
|
300
|
+
.action(async (opts: PeopleLookupOpts) => {
|
|
261
301
|
try {
|
|
262
302
|
const scope = people.opts() as PeopleScopeOpts;
|
|
263
303
|
const hqRoot = resolveHqRoot(scope);
|
|
264
304
|
const slug = resolveCompanySlug(hqRoot, scope.company);
|
|
265
|
-
|
|
305
|
+
let records = listCompanyPeople(hqRoot, slug);
|
|
306
|
+
|
|
307
|
+
if (!opts.localOnly) {
|
|
308
|
+
const roster = await tryFetchRoster(
|
|
309
|
+
deps.fetchRoster ?? fetchMembershipRoster,
|
|
310
|
+
hqRoot,
|
|
311
|
+
slug,
|
|
312
|
+
);
|
|
313
|
+
if (roster) records = mergePeople(records, roster);
|
|
314
|
+
}
|
|
315
|
+
records = records.sort((a, b) => a.name.localeCompare(b.name));
|
|
266
316
|
|
|
267
317
|
if (opts.json) {
|
|
268
318
|
console.log(JSON.stringify(records, null, 2));
|
|
@@ -288,7 +338,7 @@ export function registerPeopleCommand(
|
|
|
288
338
|
.option("--json", "Output JSON instead of a table")
|
|
289
339
|
.option(
|
|
290
340
|
"--local-only",
|
|
291
|
-
"Skip
|
|
341
|
+
"Skip network fallback; search only the local people roster",
|
|
292
342
|
)
|
|
293
343
|
.action(async (keyword: string, opts: PeopleLookupOpts) => {
|
|
294
344
|
try {
|
|
@@ -300,7 +350,7 @@ export function registerPeopleCommand(
|
|
|
300
350
|
slug,
|
|
301
351
|
keyword,
|
|
302
352
|
opts,
|
|
303
|
-
|
|
353
|
+
fetchRoster: deps.fetchRoster,
|
|
304
354
|
});
|
|
305
355
|
|
|
306
356
|
if (opts.json) {
|
|
@@ -323,7 +373,7 @@ export function registerPeopleCommand(
|
|
|
323
373
|
.option("--json", "Output JSON instead of plain text")
|
|
324
374
|
.option(
|
|
325
375
|
"--local-only",
|
|
326
|
-
"Skip
|
|
376
|
+
"Skip network fallback; resolve only from the local people roster",
|
|
327
377
|
)
|
|
328
378
|
.action(async (name: string, opts: PeopleLookupOpts) => {
|
|
329
379
|
try {
|
|
@@ -335,7 +385,7 @@ export function registerPeopleCommand(
|
|
|
335
385
|
slug,
|
|
336
386
|
name,
|
|
337
387
|
opts,
|
|
338
|
-
|
|
388
|
+
fetchRoster: deps.fetchRoster,
|
|
339
389
|
});
|
|
340
390
|
|
|
341
391
|
if (opts.json) {
|
|
@@ -104,6 +104,7 @@ function jsonRes(body: unknown, status = 200): Response {
|
|
|
104
104
|
describe("secrets sandbox", () => {
|
|
105
105
|
let stdoutSpy: MockInstance<typeof process.stdout.write>;
|
|
106
106
|
let stderrSpy: MockInstance<typeof process.stderr.write>;
|
|
107
|
+
let exitSpy: MockInstance<typeof process.exit>;
|
|
107
108
|
let startJobSpy: MockInstance<SandboxRunnerClient["startJob"]>;
|
|
108
109
|
let pollJobSpy: MockInstance<SandboxRunnerClient["pollJob"]>;
|
|
109
110
|
let getJobSpy: MockInstance<SandboxRunnerClient["getJob"]>;
|
|
@@ -119,9 +120,14 @@ describe("secrets sandbox", () => {
|
|
|
119
120
|
.mockResolvedValue({
|
|
120
121
|
jobId: "job_123",
|
|
121
122
|
status: "succeeded",
|
|
122
|
-
|
|
123
|
+
output: "done\n",
|
|
124
|
+
exitCode: 0,
|
|
125
|
+
success: true,
|
|
123
126
|
});
|
|
124
127
|
getJobSpy = vi.spyOn(SandboxRunnerClient.prototype, "getJob");
|
|
128
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {
|
|
129
|
+
throw new Error("__exit__");
|
|
130
|
+
}) as never);
|
|
125
131
|
});
|
|
126
132
|
|
|
127
133
|
it("registers the sandbox channel value", () => {
|
|
@@ -129,7 +135,7 @@ describe("secrets sandbox", () => {
|
|
|
129
135
|
expect(channel).toBe("sandbox");
|
|
130
136
|
});
|
|
131
137
|
|
|
132
|
-
it("parses --company, --only, and
|
|
138
|
+
it("parses --company, --only, and joins args after -- into a command", async () => {
|
|
133
139
|
const program = buildProgram();
|
|
134
140
|
await program.parseAsync([
|
|
135
141
|
"node",
|
|
@@ -141,22 +147,59 @@ describe("secrets sandbox", () => {
|
|
|
141
147
|
"--only",
|
|
142
148
|
"API_KEY,OTHER_KEY",
|
|
143
149
|
"--",
|
|
144
|
-
"
|
|
150
|
+
"node",
|
|
151
|
+
"-e",
|
|
152
|
+
"console.log('hi')",
|
|
145
153
|
"--days",
|
|
146
154
|
"7",
|
|
147
155
|
]);
|
|
148
156
|
|
|
149
157
|
expect(startJobSpy).toHaveBeenCalledWith("test-token", {
|
|
150
|
-
skillId: "pull-triple-whale",
|
|
151
158
|
companyUid: "prs_alice",
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
only: ["API_KEY", "OTHER_KEY"],
|
|
155
|
-
usage: { channel: "sandbox" },
|
|
159
|
+
secretNames: ["API_KEY", "OTHER_KEY"],
|
|
160
|
+
command: "node -e console.log('hi') --days 7",
|
|
156
161
|
});
|
|
157
162
|
expect(stdoutSpy).toHaveBeenCalledWith("done\n");
|
|
158
163
|
});
|
|
159
164
|
|
|
165
|
+
it("requires --only", async () => {
|
|
166
|
+
const program = buildProgram();
|
|
167
|
+
|
|
168
|
+
await expect(
|
|
169
|
+
program.parseAsync([
|
|
170
|
+
"node",
|
|
171
|
+
"hq",
|
|
172
|
+
"secrets",
|
|
173
|
+
"sandbox",
|
|
174
|
+
"--",
|
|
175
|
+
"env",
|
|
176
|
+
]),
|
|
177
|
+
).rejects.toThrow("__exit__");
|
|
178
|
+
|
|
179
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
180
|
+
expect(errSpy.mock.calls.flat().join(" ")).toMatch(/--only is required/i);
|
|
181
|
+
expect(startJobSpy).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("requires a command after --", async () => {
|
|
185
|
+
const program = buildProgram();
|
|
186
|
+
|
|
187
|
+
await expect(
|
|
188
|
+
program.parseAsync([
|
|
189
|
+
"node",
|
|
190
|
+
"hq",
|
|
191
|
+
"secrets",
|
|
192
|
+
"sandbox",
|
|
193
|
+
"--only",
|
|
194
|
+
"API_KEY",
|
|
195
|
+
]),
|
|
196
|
+
).rejects.toThrow("__exit__");
|
|
197
|
+
|
|
198
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
199
|
+
expect(errSpy.mock.calls.flat().join(" ")).toMatch(/no command specified/i);
|
|
200
|
+
expect(startJobSpy).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
|
|
160
203
|
it("resolves identity before posting the runner job", async () => {
|
|
161
204
|
const program = buildProgram();
|
|
162
205
|
await program.parseAsync([
|
|
@@ -166,8 +209,10 @@ describe("secrets sandbox", () => {
|
|
|
166
209
|
"--company",
|
|
167
210
|
"parent-co",
|
|
168
211
|
"sandbox",
|
|
212
|
+
"--only",
|
|
213
|
+
"API_KEY",
|
|
169
214
|
"--",
|
|
170
|
-
"
|
|
215
|
+
"env",
|
|
171
216
|
]);
|
|
172
217
|
|
|
173
218
|
expect(ensureCognitoToken).toHaveBeenCalled();
|
|
@@ -175,58 +220,20 @@ describe("secrets sandbox", () => {
|
|
|
175
220
|
personal: false,
|
|
176
221
|
companySlug: "parent-co",
|
|
177
222
|
});
|
|
178
|
-
expect(startJobSpy).toHaveBeenCalledWith(
|
|
179
|
-
"
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
companySlug: "parent-co",
|
|
184
|
-
usage: { channel: "sandbox" },
|
|
185
|
-
}),
|
|
186
|
-
);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it("can attach script attestation metadata to sandbox usage", async () => {
|
|
190
|
-
const scriptPath = join(tempDir, "sandbox-script.sh");
|
|
191
|
-
const scriptBody = "#!/usr/bin/env bash\necho sandbox\n";
|
|
192
|
-
writeFileSync(scriptPath, scriptBody);
|
|
193
|
-
const expectedSha = createHash("sha256").update(scriptBody).digest("hex");
|
|
194
|
-
|
|
195
|
-
const program = buildProgram();
|
|
196
|
-
await program.parseAsync([
|
|
197
|
-
"node",
|
|
198
|
-
"hq",
|
|
199
|
-
"secrets",
|
|
200
|
-
"sandbox",
|
|
201
|
-
"--script",
|
|
202
|
-
scriptPath,
|
|
203
|
-
"--",
|
|
204
|
-
"my-skill",
|
|
205
|
-
]);
|
|
206
|
-
|
|
207
|
-
expect(startJobSpy).toHaveBeenCalledWith(
|
|
208
|
-
"test-token",
|
|
209
|
-
expect.objectContaining({
|
|
210
|
-
usage: {
|
|
211
|
-
channel: "sandbox",
|
|
212
|
-
script: {
|
|
213
|
-
scriptId: scriptPath,
|
|
214
|
-
path: scriptPath,
|
|
215
|
-
sha256: expectedSha,
|
|
216
|
-
attestationLevel: "self-asserted-hash",
|
|
217
|
-
},
|
|
218
|
-
},
|
|
219
|
-
}),
|
|
220
|
-
);
|
|
223
|
+
expect(startJobSpy).toHaveBeenCalledWith("test-token", {
|
|
224
|
+
companyUid: "prs_alice",
|
|
225
|
+
secretNames: ["API_KEY"],
|
|
226
|
+
command: "env",
|
|
227
|
+
});
|
|
221
228
|
});
|
|
222
229
|
|
|
223
|
-
it("
|
|
230
|
+
it("prints server-scrubbed output and never batch-loads secrets locally", async () => {
|
|
224
231
|
pollJobSpy.mockResolvedValueOnce({
|
|
225
232
|
jobId: "job_123",
|
|
226
233
|
status: "succeeded",
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
234
|
+
output: "token [REDACTED]\nAPI_KEY=[REDACTED]\n",
|
|
235
|
+
exitCode: 0,
|
|
236
|
+
success: true,
|
|
230
237
|
});
|
|
231
238
|
|
|
232
239
|
const program = buildProgram();
|
|
@@ -253,37 +260,36 @@ describe("secrets sandbox", () => {
|
|
|
253
260
|
...logSpy.mock.calls.flat().map(String),
|
|
254
261
|
...errSpy.mock.calls.flat().map(String),
|
|
255
262
|
].join("\n");
|
|
256
|
-
expect(rendered).not.toContain("sk-test-secret-value");
|
|
257
|
-
expect(rendered).not.toContain("sk-another-secret");
|
|
258
|
-
expect(rendered).not.toContain("sk-stderr-secret");
|
|
259
|
-
expect(rendered).not.toContain("sk-log-secret");
|
|
260
263
|
expect(rendered).toContain("[REDACTED]");
|
|
264
|
+
expect(stdoutSpy).toHaveBeenCalledWith("token [REDACTED]\nAPI_KEY=[REDACTED]\n");
|
|
261
265
|
});
|
|
262
266
|
|
|
263
|
-
it("
|
|
267
|
+
it("exits non-zero when the sandbox command fails", async () => {
|
|
264
268
|
pollJobSpy.mockResolvedValueOnce({
|
|
265
269
|
jobId: "job_123",
|
|
266
|
-
status: "
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
+
status: "failed",
|
|
271
|
+
output: "boom\n",
|
|
272
|
+
exitCode: 7,
|
|
273
|
+
success: false,
|
|
270
274
|
});
|
|
271
275
|
|
|
272
276
|
const program = buildProgram();
|
|
273
|
-
await
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
277
|
+
await expect(
|
|
278
|
+
program.parseAsync([
|
|
279
|
+
"node",
|
|
280
|
+
"hq",
|
|
281
|
+
"secrets",
|
|
282
|
+
"sandbox",
|
|
283
|
+
"--only",
|
|
284
|
+
"API_KEY",
|
|
285
|
+
"--",
|
|
286
|
+
"my-skill",
|
|
287
|
+
]),
|
|
288
|
+
).rejects.toThrow("__exit__");
|
|
283
289
|
|
|
284
|
-
expect(stdoutSpy).toHaveBeenCalledWith("
|
|
285
|
-
expect(
|
|
286
|
-
expect(
|
|
290
|
+
expect(stdoutSpy).toHaveBeenCalledWith("boom\n");
|
|
291
|
+
expect(errSpy.mock.calls.flat().join(" ")).toMatch(/exit code 7/i);
|
|
292
|
+
expect(exitSpy).toHaveBeenCalledWith(7);
|
|
287
293
|
expect(getJobSpy).not.toHaveBeenCalled();
|
|
288
294
|
});
|
|
289
295
|
});
|
package/src/commands/secrets.ts
CHANGED
|
@@ -368,14 +368,8 @@ export function scrubSandboxOutput(text: string, secretNames: string[] = []): st
|
|
|
368
368
|
}
|
|
369
369
|
|
|
370
370
|
function renderSandboxJobResult(job: SandboxRunnerJob, secretNames: string[]): void {
|
|
371
|
-
if (job.
|
|
372
|
-
process.stdout.write(scrubSandboxOutput(job.
|
|
373
|
-
}
|
|
374
|
-
if (job.stderr) {
|
|
375
|
-
process.stderr.write(scrubSandboxOutput(job.stderr, secretNames));
|
|
376
|
-
}
|
|
377
|
-
if (job.logsTail) {
|
|
378
|
-
process.stderr.write(scrubSandboxOutput(job.logsTail, secretNames));
|
|
371
|
+
if (job.output) {
|
|
372
|
+
process.stdout.write(scrubSandboxOutput(job.output, secretNames));
|
|
379
373
|
}
|
|
380
374
|
}
|
|
381
375
|
|
|
@@ -1249,38 +1243,41 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
1249
1243
|
|
|
1250
1244
|
secrets
|
|
1251
1245
|
.command("sandbox")
|
|
1252
|
-
.description("Run a
|
|
1246
|
+
.description("Run a command in the hosted sandbox with named secrets injected as env vars; open egress, secrets never touch this machine")
|
|
1253
1247
|
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
1254
1248
|
.option(
|
|
1255
1249
|
"--personal",
|
|
1256
1250
|
"Operate on the caller's personal vault (no sharing)",
|
|
1257
1251
|
)
|
|
1258
|
-
.option("--only <keys>", "Comma-separated list of secret names
|
|
1259
|
-
.option("--script <path>", "Attach local script identity for script-locked secrets")
|
|
1252
|
+
.option("--only <keys>", "Comma-separated list of secret names to inject (required)")
|
|
1260
1253
|
.allowUnknownOption(true)
|
|
1261
|
-
.action(async (opts: { company?: string; personal?: boolean; only?: string
|
|
1254
|
+
.action(async (opts: { company?: string; personal?: boolean; only?: string }, cmd: Command) => {
|
|
1262
1255
|
try {
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1256
|
+
if (!opts.only || opts.only.trim().length === 0) {
|
|
1257
|
+
console.error(chalk.red("Error: --only is required and must name at least one secret."));
|
|
1258
|
+
process.exit(1);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const rawArgs = cmd.args;
|
|
1262
|
+
const command = rawArgs.join(" ").trim();
|
|
1263
|
+
if (command.length === 0) {
|
|
1264
|
+
console.error(chalk.red("Error: no command specified. Usage: hq secrets sandbox --company <slug> --only KEY1,KEY2 -- <command>"));
|
|
1265
|
+
process.exit(1);
|
|
1266
|
+
}
|
|
1267
|
+
if (command.length > 8192) {
|
|
1268
|
+
console.error(chalk.red("Error: sandbox command exceeds the 8192 character limit."));
|
|
1267
1269
|
process.exit(1);
|
|
1268
1270
|
}
|
|
1269
1271
|
|
|
1270
|
-
const
|
|
1271
|
-
const keys = opts.only ? parseSecretNameList(opts.only) : [];
|
|
1272
|
+
const keys = parseSecretNameList(opts.only);
|
|
1272
1273
|
const token = await ensureCognitoToken();
|
|
1273
1274
|
const scope = scopeOpts(mergeScopeOpts(secrets.opts(), opts));
|
|
1274
1275
|
const companyUid = await getEntityUid(token, scope);
|
|
1275
|
-
const usage = await buildSecretUsage("sandbox", opts.script);
|
|
1276
1276
|
const client = new SandboxRunnerClient();
|
|
1277
1277
|
const started = await client.startJob(token, {
|
|
1278
|
-
skillId,
|
|
1279
1278
|
companyUid,
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
only: keys.length > 0 ? keys : undefined,
|
|
1283
|
-
usage,
|
|
1279
|
+
secretNames: keys,
|
|
1280
|
+
command,
|
|
1284
1281
|
});
|
|
1285
1282
|
const job =
|
|
1286
1283
|
started.status === "succeeded" || started.status === "failed"
|
|
@@ -1288,14 +1285,11 @@ export function registerSecretsCommand(program: Command): void {
|
|
|
1288
1285
|
: await client.pollJob(token, started.jobId);
|
|
1289
1286
|
|
|
1290
1287
|
renderSandboxJobResult(job, keys);
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
}
|
|
1295
|
-
process.exit(
|
|
1296
|
-
}
|
|
1297
|
-
if (job.exitCode && job.exitCode !== 0) {
|
|
1298
|
-
process.exit(job.exitCode);
|
|
1288
|
+
const exitCode = typeof job.exitCode === "number" ? job.exitCode : undefined;
|
|
1289
|
+
if (job.success === false || (exitCode !== undefined && exitCode !== 0) || job.status === "failed") {
|
|
1290
|
+
const code = exitCode && exitCode !== 0 ? exitCode : 1;
|
|
1291
|
+
console.error(chalk.red(`Sandbox command failed with exit code ${code}.`));
|
|
1292
|
+
process.exit(code);
|
|
1299
1293
|
}
|
|
1300
1294
|
} catch (err) {
|
|
1301
1295
|
console.error(
|