@indigoai-us/hq-cli 5.5.0 → 5.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/hq-auth-refresh.js +0 -0
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +2 -1
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/auth.d.ts +0 -3
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +0 -3
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/cloud.d.ts +12 -2
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +112 -80
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +18 -1
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/login.d.ts +1 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +28 -9
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/logout.d.ts +1 -1
- package/dist/commands/logout.d.ts.map +1 -1
- package/dist/commands/logout.js +8 -3
- package/dist/commands/logout.js.map +1 -1
- package/dist/commands/onboard.d.ts +23 -0
- package/dist/commands/onboard.d.ts.map +1 -0
- package/dist/commands/onboard.js +142 -0
- package/dist/commands/onboard.js.map +1 -0
- package/dist/commands/pack-install.d.ts +44 -0
- package/dist/commands/pack-install.d.ts.map +1 -0
- package/dist/commands/pack-install.js +513 -0
- package/dist/commands/pack-install.js.map +1 -0
- package/dist/commands/pkg-install.d.ts.map +1 -1
- package/dist/commands/pkg-install.js +20 -8
- package/dist/commands/pkg-install.js.map +1 -1
- package/dist/commands/pkg-list.js +4 -4
- package/dist/commands/pkg-list.js.map +1 -1
- package/dist/commands/pkg-update.js +3 -3
- package/dist/commands/pkg-update.js.map +1 -1
- package/dist/commands/secrets.d.ts +3 -0
- package/dist/commands/secrets.d.ts.map +1 -0
- package/dist/commands/secrets.js +521 -0
- package/dist/commands/secrets.js.map +1 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +10 -2
- package/dist/commands/sync.js.map +1 -1
- package/dist/commands/team-sync.d.ts.map +1 -1
- package/dist/commands/team-sync.js +6 -6
- package/dist/commands/team-sync.js.map +1 -1
- package/dist/commands/whoami.d.ts +1 -1
- package/dist/commands/whoami.d.ts.map +1 -1
- package/dist/commands/whoami.js +23 -7
- package/dist/commands/whoami.js.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/strategies/link.d.ts +2 -2
- package/dist/strategies/link.d.ts.map +1 -1
- package/dist/strategies/link.js.map +1 -1
- package/dist/strategies/merge.d.ts +2 -2
- package/dist/strategies/merge.d.ts.map +1 -1
- package/dist/strategies/merge.js.map +1 -1
- package/dist/types.d.ts +37 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -1
- package/dist/utils/cognito-session.d.ts +18 -7
- package/dist/utils/cognito-session.d.ts.map +1 -1
- package/dist/utils/cognito-session.js +35 -8
- package/dist/utils/cognito-session.js.map +1 -1
- package/dist/utils/manifest.d.ts +13 -0
- package/dist/utils/manifest.d.ts.map +1 -1
- package/dist/utils/manifest.js +24 -1
- package/dist/utils/manifest.js.map +1 -1
- package/dist/utils/secrets-cache.d.ts +7 -0
- package/dist/utils/secrets-cache.d.ts.map +1 -0
- package/dist/utils/secrets-cache.js +134 -0
- package/dist/utils/secrets-cache.js.map +1 -0
- package/package.json +15 -5
- package/src/commands/add.ts +7 -6
- package/src/commands/auth.ts +0 -3
- package/src/commands/cloud.ts +214 -101
- package/src/commands/list.ts +20 -1
- package/src/commands/login.ts +28 -9
- package/src/commands/logout.ts +8 -3
- package/src/commands/onboard.ts +193 -0
- package/src/commands/pack-install.ts +667 -0
- package/src/commands/pkg-install.ts +34 -16
- package/src/commands/pkg-list.ts +4 -4
- package/src/commands/pkg-update.ts +3 -3
- package/src/commands/secrets.ts +670 -0
- package/src/commands/sync.ts +20 -4
- package/src/commands/team-sync.ts +7 -8
- package/src/commands/whoami.ts +22 -7
- package/src/index.ts +9 -1
- package/src/strategies/link.ts +2 -2
- package/src/strategies/merge.ts +2 -2
- package/src/types.ts +54 -4
- package/src/utils/cognito-session.ts +46 -14
- package/src/utils/manifest.test.ts +148 -0
- package/src/utils/manifest.ts +22 -1
- package/src/utils/secrets-cache.ts +129 -0
- package/tsconfig.json +2 -1
- package/src/utils/auth.ts +0 -193
- package/src/utils/token-store.ts +0 -83
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import * as readline from "node:readline";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import {
|
|
6
|
+
ensureCognitoToken,
|
|
7
|
+
DEFAULT_VAULT_API_URL,
|
|
8
|
+
} from "../utils/cognito-session.js";
|
|
9
|
+
import {
|
|
10
|
+
readCache,
|
|
11
|
+
writeCache,
|
|
12
|
+
removeCacheEntry,
|
|
13
|
+
clearAllCache,
|
|
14
|
+
} from "../utils/secrets-cache.js";
|
|
15
|
+
|
|
16
|
+
interface VaultApiOptions {
|
|
17
|
+
token: string;
|
|
18
|
+
path: string;
|
|
19
|
+
method?: string;
|
|
20
|
+
body?: Record<string, unknown>;
|
|
21
|
+
query?: Record<string, string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function shellSingleQuote(value: string): string {
|
|
25
|
+
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function vaultApiFetch(opts: VaultApiOptions): Promise<Response> {
|
|
29
|
+
const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
|
|
30
|
+
if (opts.query) {
|
|
31
|
+
for (const [k, v] of Object.entries(opts.query)) {
|
|
32
|
+
url.searchParams.set(k, v);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return fetch(url.toString(), {
|
|
36
|
+
method: opts.method ?? "GET",
|
|
37
|
+
headers: {
|
|
38
|
+
Authorization: `Bearer ${opts.token}`,
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
},
|
|
41
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function resolveCompanyUid(
|
|
46
|
+
token: string,
|
|
47
|
+
slug: string,
|
|
48
|
+
): Promise<string> {
|
|
49
|
+
const res = await vaultApiFetch({
|
|
50
|
+
token,
|
|
51
|
+
path: `/entity/by-slug/company/${encodeURIComponent(slug)}`,
|
|
52
|
+
});
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
const body = await res.json().catch(() => ({}));
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Failed to resolve company slug '${slug}': ${(body as Record<string, string>).error ?? res.statusText}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const data = (await res.json()) as { entity: { uid: string } };
|
|
60
|
+
return data.entity.uid;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface MembershipEntry {
|
|
64
|
+
companyUid: string;
|
|
65
|
+
role: string;
|
|
66
|
+
status: string;
|
|
67
|
+
membershipKey: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function resolveCompanyFromMemberships(
|
|
71
|
+
token: string,
|
|
72
|
+
): Promise<string> {
|
|
73
|
+
const res = await vaultApiFetch({
|
|
74
|
+
token,
|
|
75
|
+
path: "/membership/me",
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
throw new Error("Failed to fetch memberships — run `hq login` and try again");
|
|
79
|
+
}
|
|
80
|
+
const data = (await res.json()) as { memberships: MembershipEntry[] };
|
|
81
|
+
const active = data.memberships.filter((m) => m.status === "active");
|
|
82
|
+
if (active.length === 0) {
|
|
83
|
+
throw new Error("No active company memberships found. Use --company <slug> to specify.");
|
|
84
|
+
}
|
|
85
|
+
if (active.length === 1) {
|
|
86
|
+
return active[0].companyUid;
|
|
87
|
+
}
|
|
88
|
+
const uids = active.map((m) => m.companyUid).join(", ");
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Multiple companies found (${uids}). Use --company <slug> to specify which one.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function getCompanyUid(
|
|
95
|
+
token: string,
|
|
96
|
+
companySlug: string | undefined,
|
|
97
|
+
): Promise<string> {
|
|
98
|
+
if (companySlug) {
|
|
99
|
+
return resolveCompanyUid(token, companySlug);
|
|
100
|
+
}
|
|
101
|
+
return resolveCompanyFromMemberships(token);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseDuration(input: string): number | null {
|
|
105
|
+
const match = input.match(/^(\d+)(m|h|d)$/);
|
|
106
|
+
if (!match) return null;
|
|
107
|
+
const value = parseInt(match[1], 10);
|
|
108
|
+
const unit = match[2];
|
|
109
|
+
if (unit === "m") return value * 60 * 1000;
|
|
110
|
+
if (unit === "h") return value * 60 * 60 * 1000;
|
|
111
|
+
if (unit === "d") return value * 24 * 60 * 60 * 1000;
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function confirmPrompt(message: string): Promise<boolean> {
|
|
116
|
+
const rl = readline.createInterface({
|
|
117
|
+
input: process.stdin,
|
|
118
|
+
output: process.stdout,
|
|
119
|
+
});
|
|
120
|
+
return new Promise((resolve) => {
|
|
121
|
+
rl.question(`${message} [y/N] `, (answer) => {
|
|
122
|
+
rl.close();
|
|
123
|
+
resolve(answer.trim().toLowerCase() === "y");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readFromPipedStdin(): Promise<string> {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
let data = "";
|
|
131
|
+
process.stdin.setEncoding("utf8");
|
|
132
|
+
process.stdin.on("data", (chunk) => {
|
|
133
|
+
data += chunk;
|
|
134
|
+
});
|
|
135
|
+
process.stdin.on("end", () => resolve(data.replace(/\n$/, "")));
|
|
136
|
+
process.stdin.on("error", reject);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function promptSecretInteractively(): Promise<string> {
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
process.stdout.write("Enter secret value: ");
|
|
143
|
+
|
|
144
|
+
if (process.stdin.isTTY) {
|
|
145
|
+
process.stdin.setRawMode(true);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let value = "";
|
|
149
|
+
process.stdin.setEncoding("utf8");
|
|
150
|
+
|
|
151
|
+
const cleanup = () => {
|
|
152
|
+
if (process.stdin.isTTY) {
|
|
153
|
+
process.stdin.setRawMode(false);
|
|
154
|
+
}
|
|
155
|
+
process.stdin.removeListener("data", onData);
|
|
156
|
+
process.stdin.removeListener("end", onEnd);
|
|
157
|
+
process.stdin.pause();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const onEnd = () => {
|
|
161
|
+
cleanup();
|
|
162
|
+
process.stdout.write("\n");
|
|
163
|
+
resolve(value);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const onData = (ch: string) => {
|
|
167
|
+
for (const c of ch) {
|
|
168
|
+
const code = c.codePointAt(0)!;
|
|
169
|
+
if (c === "\n" || c === "\r" || code === 4) {
|
|
170
|
+
cleanup();
|
|
171
|
+
process.stdout.write("\n");
|
|
172
|
+
resolve(value);
|
|
173
|
+
return;
|
|
174
|
+
} else if (code === 3) {
|
|
175
|
+
cleanup();
|
|
176
|
+
reject(new Error("Aborted"));
|
|
177
|
+
return;
|
|
178
|
+
} else if (code === 0x1b) {
|
|
179
|
+
return;
|
|
180
|
+
} else if (code === 127) {
|
|
181
|
+
if (value.length > 0) {
|
|
182
|
+
value = value.slice(0, -1);
|
|
183
|
+
}
|
|
184
|
+
} else if (code >= 0x20) {
|
|
185
|
+
value += c;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
process.stdin.on("data", onData);
|
|
191
|
+
process.stdin.on("end", onEnd);
|
|
192
|
+
process.stdin.resume();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function registerSecretsCommand(program: Command): void {
|
|
197
|
+
const secrets = program
|
|
198
|
+
.command("secrets")
|
|
199
|
+
.description("Manage secrets in HQ vault (SSM Parameter Store)")
|
|
200
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)");
|
|
201
|
+
|
|
202
|
+
secrets
|
|
203
|
+
.command("set <name>")
|
|
204
|
+
.description("Create or update a secret")
|
|
205
|
+
.option("--from-stdin", "Read secret value from piped stdin")
|
|
206
|
+
.action(async (name: string, opts: { fromStdin?: boolean }) => {
|
|
207
|
+
try {
|
|
208
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {
|
|
209
|
+
console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*$ (e.g. MY_API_KEY)`));
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let value: string;
|
|
214
|
+
if (opts.fromStdin) {
|
|
215
|
+
if (process.stdin.isTTY) {
|
|
216
|
+
console.error(chalk.red("Error: --from-stdin requires piped input (e.g. echo 'val' | hq secrets set NAME --from-stdin)"));
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
value = await readFromPipedStdin();
|
|
220
|
+
} else {
|
|
221
|
+
if (!process.stdin.isTTY) {
|
|
222
|
+
console.error(chalk.red("Error: stdin is not a terminal. Use --from-stdin for piped input."));
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
value = await promptSecretInteractively();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!value) {
|
|
229
|
+
console.error(chalk.red("Error: secret value cannot be empty."));
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (Buffer.byteLength(value, "utf8") > 4096) {
|
|
234
|
+
console.error(chalk.red(`Secret value exceeds 4096-byte SSM limit (got ${Buffer.byteLength(value, "utf8")} bytes).`));
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const token = await ensureCognitoToken();
|
|
239
|
+
const companySlug = secrets.opts().company as string | undefined;
|
|
240
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
241
|
+
|
|
242
|
+
const res = await vaultApiFetch({
|
|
243
|
+
token,
|
|
244
|
+
path: `/secrets/${encodeURIComponent(companyUid)}`,
|
|
245
|
+
method: "POST",
|
|
246
|
+
body: { name, value },
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (!res.ok) {
|
|
250
|
+
const body = await res.json().catch(() => ({}));
|
|
251
|
+
console.error(
|
|
252
|
+
chalk.red(`Failed to set secret: ${(body as Record<string, string>).error ?? res.statusText}`),
|
|
253
|
+
);
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
removeCacheEntry(companyUid, name);
|
|
258
|
+
console.log(chalk.green(`Secret '${name}' saved.`));
|
|
259
|
+
} catch (err) {
|
|
260
|
+
console.error(
|
|
261
|
+
chalk.red("Error:"),
|
|
262
|
+
err instanceof Error ? err.message : String(err),
|
|
263
|
+
);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
secrets
|
|
269
|
+
.command("get <name>")
|
|
270
|
+
.description("Get a secret's metadata (use --reveal for value)")
|
|
271
|
+
.option("--reveal", "Include the decrypted secret value")
|
|
272
|
+
.action(async (name: string, opts: { reveal?: boolean }) => {
|
|
273
|
+
try {
|
|
274
|
+
const token = await ensureCognitoToken();
|
|
275
|
+
const companySlug = secrets.opts().company as string | undefined;
|
|
276
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
277
|
+
|
|
278
|
+
const query: Record<string, string> = {};
|
|
279
|
+
if (opts.reveal) {
|
|
280
|
+
query.reveal = "true";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const res = await vaultApiFetch({
|
|
284
|
+
token,
|
|
285
|
+
path: `/secrets/${encodeURIComponent(companyUid)}/${encodeURIComponent(name)}`,
|
|
286
|
+
query: Object.keys(query).length > 0 ? query : undefined,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (!res.ok) {
|
|
290
|
+
const body = await res.json().catch(() => ({}));
|
|
291
|
+
console.error(
|
|
292
|
+
chalk.red(`Failed to get secret: ${(body as Record<string, string>).error ?? res.statusText}`),
|
|
293
|
+
);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const data = (await res.json()) as {
|
|
298
|
+
secret: {
|
|
299
|
+
name: string;
|
|
300
|
+
companyUid: string;
|
|
301
|
+
type?: string;
|
|
302
|
+
lastModifiedDate?: string;
|
|
303
|
+
version?: number;
|
|
304
|
+
value?: string;
|
|
305
|
+
};
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const s = data.secret;
|
|
309
|
+
console.log(chalk.bold(`Secret: ${s.name}`));
|
|
310
|
+
if (s.lastModifiedDate) {
|
|
311
|
+
console.log(` Last Modified: ${s.lastModifiedDate}`);
|
|
312
|
+
}
|
|
313
|
+
if (s.version != null) {
|
|
314
|
+
console.log(` Version: ${s.version}`);
|
|
315
|
+
}
|
|
316
|
+
if (opts.reveal && s.value != null) {
|
|
317
|
+
console.log(` Value: ${s.value}`);
|
|
318
|
+
} else {
|
|
319
|
+
console.log(` Value: ${chalk.dim("[REDACTED]")}`);
|
|
320
|
+
}
|
|
321
|
+
} catch (err) {
|
|
322
|
+
console.error(
|
|
323
|
+
chalk.red("Error:"),
|
|
324
|
+
err instanceof Error ? err.message : String(err),
|
|
325
|
+
);
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
secrets
|
|
331
|
+
.command("list")
|
|
332
|
+
.description("List all secrets for the company")
|
|
333
|
+
.action(async () => {
|
|
334
|
+
try {
|
|
335
|
+
const token = await ensureCognitoToken();
|
|
336
|
+
const companySlug = secrets.opts().company as string | undefined;
|
|
337
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
338
|
+
|
|
339
|
+
const res = await vaultApiFetch({
|
|
340
|
+
token,
|
|
341
|
+
path: `/secrets/${encodeURIComponent(companyUid)}`,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
if (!res.ok) {
|
|
345
|
+
const body = await res.json().catch(() => ({}));
|
|
346
|
+
console.error(
|
|
347
|
+
chalk.red(`Failed to list secrets: ${(body as Record<string, string>).error ?? res.statusText}`),
|
|
348
|
+
);
|
|
349
|
+
process.exit(1);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const data = (await res.json()) as {
|
|
353
|
+
secrets: { name: string; lastModifiedDate?: string; version?: number }[];
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
if (data.secrets.length === 0) {
|
|
357
|
+
console.log(chalk.dim("No secrets found."));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const nameWidth = Math.max(4, ...data.secrets.map((s) => s.name.length));
|
|
362
|
+
const header = `${"NAME".padEnd(nameWidth)} LAST MODIFIED`;
|
|
363
|
+
console.log(chalk.bold(header));
|
|
364
|
+
for (const s of data.secrets) {
|
|
365
|
+
const modified = s.lastModifiedDate ?? "-";
|
|
366
|
+
console.log(`${s.name.padEnd(nameWidth)} ${modified}`);
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
console.error(
|
|
370
|
+
chalk.red("Error:"),
|
|
371
|
+
err instanceof Error ? err.message : String(err),
|
|
372
|
+
);
|
|
373
|
+
process.exit(1);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
secrets
|
|
378
|
+
.command("delete <name>")
|
|
379
|
+
.description("Delete a secret")
|
|
380
|
+
.option("--force", "Skip confirmation prompt")
|
|
381
|
+
.action(async (name: string, opts: { force?: boolean }) => {
|
|
382
|
+
try {
|
|
383
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {
|
|
384
|
+
console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*$ (e.g. MY_API_KEY)`));
|
|
385
|
+
process.exit(1);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!opts.force) {
|
|
389
|
+
const confirmed = await confirmPrompt(
|
|
390
|
+
`Delete secret '${name}'? This cannot be undone.`,
|
|
391
|
+
);
|
|
392
|
+
if (!confirmed) {
|
|
393
|
+
console.log(chalk.dim("Aborted."));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const token = await ensureCognitoToken();
|
|
399
|
+
const companySlug = secrets.opts().company as string | undefined;
|
|
400
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
401
|
+
|
|
402
|
+
const res = await vaultApiFetch({
|
|
403
|
+
token,
|
|
404
|
+
path: `/secrets/${encodeURIComponent(companyUid)}/${encodeURIComponent(name)}`,
|
|
405
|
+
method: "DELETE",
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
if (!res.ok) {
|
|
409
|
+
const body = await res.json().catch(() => ({}));
|
|
410
|
+
console.error(
|
|
411
|
+
chalk.red(`Failed to delete secret: ${(body as Record<string, string>).error ?? res.statusText}`),
|
|
412
|
+
);
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
removeCacheEntry(companyUid, name);
|
|
417
|
+
console.log(chalk.green(`Secret '${name}' deleted.`));
|
|
418
|
+
} catch (err) {
|
|
419
|
+
console.error(
|
|
420
|
+
chalk.red("Error:"),
|
|
421
|
+
err instanceof Error ? err.message : String(err),
|
|
422
|
+
);
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
secrets
|
|
428
|
+
.command("exec")
|
|
429
|
+
.description("Run a command with secrets injected as env vars")
|
|
430
|
+
.requiredOption("--only <keys>", "Comma-separated list of secret names to inject (required)")
|
|
431
|
+
.allowUnknownOption(true)
|
|
432
|
+
.action(async (_opts: { only: string }, cmd: Command) => {
|
|
433
|
+
try {
|
|
434
|
+
const rawArgs = cmd.args;
|
|
435
|
+
const dashIndex = process.argv.indexOf("--");
|
|
436
|
+
let childArgs: string[];
|
|
437
|
+
if (dashIndex !== -1) {
|
|
438
|
+
childArgs = process.argv.slice(dashIndex + 1);
|
|
439
|
+
} else {
|
|
440
|
+
childArgs = rawArgs;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (childArgs.length === 0) {
|
|
444
|
+
console.error(chalk.red("Error: no command specified. Usage: hq secrets exec --only KEY1,KEY2 -- <command>"));
|
|
445
|
+
process.exit(1);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const keys = _opts.only.split(",").map((k) => k.trim()).filter(Boolean);
|
|
449
|
+
if (keys.length === 0) {
|
|
450
|
+
console.error(chalk.red("Error: --only requires at least one secret name."));
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
for (const key of keys) {
|
|
455
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
|
|
456
|
+
console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*$`));
|
|
457
|
+
process.exit(1);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const token = await ensureCognitoToken();
|
|
462
|
+
const companySlug = secrets.opts().company as string | undefined;
|
|
463
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
464
|
+
|
|
465
|
+
const revealed = await Promise.all(
|
|
466
|
+
keys.map(async (key) => {
|
|
467
|
+
const cached = readCache(companyUid, key);
|
|
468
|
+
if (cached !== null) {
|
|
469
|
+
return { key, value: cached };
|
|
470
|
+
}
|
|
471
|
+
const res = await vaultApiFetch({
|
|
472
|
+
token,
|
|
473
|
+
path: `/secrets/${encodeURIComponent(companyUid)}/${encodeURIComponent(key)}`,
|
|
474
|
+
query: { reveal: "true" },
|
|
475
|
+
});
|
|
476
|
+
if (!res.ok) {
|
|
477
|
+
const body = await res.json().catch(() => ({}));
|
|
478
|
+
throw new Error(
|
|
479
|
+
`Failed to fetch secret '${key}': ${(body as Record<string, string>).error ?? res.statusText}`,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
const data = (await res.json()) as {
|
|
483
|
+
secret: { name: string; value?: string };
|
|
484
|
+
};
|
|
485
|
+
if (data.secret.value == null) {
|
|
486
|
+
throw new Error(`Secret '${key}' has no value (reveal may not be permitted).`);
|
|
487
|
+
}
|
|
488
|
+
writeCache(companyUid, key, data.secret.value);
|
|
489
|
+
return { key, value: data.secret.value };
|
|
490
|
+
}),
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
const secretEnv: Record<string, string> = {};
|
|
494
|
+
for (const { key, value } of revealed) {
|
|
495
|
+
secretEnv[key] = value;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const [childCmd, ...childCmdArgs] = childArgs;
|
|
499
|
+
const child = spawn(childCmd, childCmdArgs, {
|
|
500
|
+
stdio: "inherit",
|
|
501
|
+
env: { ...process.env, ...secretEnv },
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
child.on("error", (err) => {
|
|
505
|
+
console.error(chalk.red(`Failed to start command '${childCmd}': ${err.message}`));
|
|
506
|
+
process.exit(1);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
child.on("close", (code, signal) => {
|
|
510
|
+
if (signal) {
|
|
511
|
+
process.kill(process.pid, signal);
|
|
512
|
+
}
|
|
513
|
+
process.exit(code ?? 1);
|
|
514
|
+
});
|
|
515
|
+
} catch (err) {
|
|
516
|
+
console.error(
|
|
517
|
+
chalk.red("Error:"),
|
|
518
|
+
err instanceof Error ? err.message : String(err),
|
|
519
|
+
);
|
|
520
|
+
process.exit(1);
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
secrets
|
|
525
|
+
.command("env")
|
|
526
|
+
.description("Print 'export KEY=VALUE' lines suitable for: source <(hq secrets env --only K1,K2)")
|
|
527
|
+
.requiredOption("--only <keys>", "Comma-separated list of secret names to print (required)")
|
|
528
|
+
.action(async (opts: { only: string }) => {
|
|
529
|
+
try {
|
|
530
|
+
const redact = process.stdout.isTTY;
|
|
531
|
+
if (redact) {
|
|
532
|
+
console.error(
|
|
533
|
+
chalk.yellow(
|
|
534
|
+
"stdout is a terminal — values redacted. Use: source <(hq secrets env --only KEY1,KEY2)",
|
|
535
|
+
),
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const keys = opts.only.split(",").map((k) => k.trim()).filter(Boolean);
|
|
540
|
+
if (keys.length === 0) {
|
|
541
|
+
console.error(chalk.red("Error: --only requires at least one secret name."));
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
for (const key of keys) {
|
|
546
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
|
|
547
|
+
console.error(chalk.red(`Invalid secret name '${key}': must match ^[A-Z][A-Z0-9_]*$`));
|
|
548
|
+
process.exit(1);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const token = await ensureCognitoToken();
|
|
553
|
+
const companySlug = secrets.opts().company as string | undefined;
|
|
554
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
555
|
+
|
|
556
|
+
const revealed = await Promise.all(
|
|
557
|
+
keys.map(async (key) => {
|
|
558
|
+
const cached = readCache(companyUid, key);
|
|
559
|
+
if (cached !== null) {
|
|
560
|
+
return { key, value: cached };
|
|
561
|
+
}
|
|
562
|
+
const res = await vaultApiFetch({
|
|
563
|
+
token,
|
|
564
|
+
path: `/secrets/${encodeURIComponent(companyUid)}/${encodeURIComponent(key)}`,
|
|
565
|
+
query: { reveal: "true" },
|
|
566
|
+
});
|
|
567
|
+
if (!res.ok) {
|
|
568
|
+
const body = await res.json().catch(() => ({}));
|
|
569
|
+
throw new Error(
|
|
570
|
+
`Failed to fetch secret '${key}': ${(body as Record<string, string>).error ?? res.statusText}`,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
const data = (await res.json()) as {
|
|
574
|
+
secret: { name: string; value?: string };
|
|
575
|
+
};
|
|
576
|
+
if (data.secret.value == null) {
|
|
577
|
+
throw new Error(`Secret '${key}' has no value (reveal may not be permitted).`);
|
|
578
|
+
}
|
|
579
|
+
writeCache(companyUid, key, data.secret.value);
|
|
580
|
+
return { key, value: data.secret.value };
|
|
581
|
+
}),
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
for (const { key, value } of revealed) {
|
|
585
|
+
const out = redact ? "[REDACTED]" : value;
|
|
586
|
+
process.stdout.write(`export ${key}=${shellSingleQuote(out)}\n`);
|
|
587
|
+
}
|
|
588
|
+
} catch (err) {
|
|
589
|
+
console.error(
|
|
590
|
+
chalk.red("Error:"),
|
|
591
|
+
err instanceof Error ? err.message : String(err),
|
|
592
|
+
);
|
|
593
|
+
process.exit(1);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
secrets
|
|
598
|
+
.command("generate-link <name>")
|
|
599
|
+
.description("Generate a one-time link for someone to submit a secret value")
|
|
600
|
+
.option("--expires <duration>", "Token expiry duration (e.g. 24h, 2d, 30m)", "24h")
|
|
601
|
+
.action(async (name: string, opts: { expires: string }) => {
|
|
602
|
+
try {
|
|
603
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {
|
|
604
|
+
console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*$ (e.g. MY_API_KEY)`));
|
|
605
|
+
process.exit(1);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const expiresInMs = parseDuration(opts.expires);
|
|
609
|
+
if (expiresInMs === null) {
|
|
610
|
+
console.error(chalk.red(`Invalid duration '${opts.expires}'. Use formats like 30m, 12h, 2d.`));
|
|
611
|
+
process.exit(1);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const MAX_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
615
|
+
if (expiresInMs > MAX_EXPIRY_MS) {
|
|
616
|
+
console.error(chalk.red("Maximum expiry is 7 days (7d)."));
|
|
617
|
+
process.exit(1);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const token = await ensureCognitoToken();
|
|
621
|
+
const companySlug = secrets.opts().company as string | undefined;
|
|
622
|
+
const companyUid = await getCompanyUid(token, companySlug);
|
|
623
|
+
|
|
624
|
+
const res = await vaultApiFetch({
|
|
625
|
+
token,
|
|
626
|
+
path: `/secrets/${encodeURIComponent(companyUid)}/${encodeURIComponent(name)}/generate-token`,
|
|
627
|
+
method: "POST",
|
|
628
|
+
body: { expiresInMs },
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
if (!res.ok) {
|
|
632
|
+
const body = await res.json().catch(() => ({}));
|
|
633
|
+
console.error(
|
|
634
|
+
chalk.red(`Failed to generate link: ${(body as Record<string, string>).error ?? res.statusText}`),
|
|
635
|
+
);
|
|
636
|
+
process.exit(1);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const data = (await res.json()) as {
|
|
640
|
+
url: string;
|
|
641
|
+
expiresAt: string;
|
|
642
|
+
secretName: string;
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
console.log(chalk.green("Secret input link generated:"));
|
|
646
|
+
console.log(`\n ${data.url}\n`);
|
|
647
|
+
console.log(chalk.dim(` Secret: ${data.secretName}`));
|
|
648
|
+
console.log(chalk.dim(` Expires: ${data.expiresAt}`));
|
|
649
|
+
console.log(chalk.dim(" One-time use — link is invalidated after first submission."));
|
|
650
|
+
} catch (err) {
|
|
651
|
+
console.error(
|
|
652
|
+
chalk.red("Error:"),
|
|
653
|
+
err instanceof Error ? err.message : String(err),
|
|
654
|
+
);
|
|
655
|
+
process.exit(1);
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const cache = secrets
|
|
660
|
+
.command("cache")
|
|
661
|
+
.description("Manage the local secrets cache");
|
|
662
|
+
|
|
663
|
+
cache
|
|
664
|
+
.command("clear")
|
|
665
|
+
.description("Clear all cached secrets")
|
|
666
|
+
.action(async () => {
|
|
667
|
+
clearAllCache();
|
|
668
|
+
console.log(chalk.green("Secrets cache cleared."));
|
|
669
|
+
});
|
|
670
|
+
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -23,10 +23,10 @@ import {
|
|
|
23
23
|
} from '../utils/git.js';
|
|
24
24
|
import { linkSync } from '../strategies/link.js';
|
|
25
25
|
import { mergeSync } from '../strategies/merge.js';
|
|
26
|
-
import type {
|
|
26
|
+
import type { LegacyModuleDefinition, ModuleLock, SyncResult } from '../types.js';
|
|
27
27
|
|
|
28
28
|
async function syncModule(
|
|
29
|
-
module:
|
|
29
|
+
module: LegacyModuleDefinition,
|
|
30
30
|
moduleDir: string,
|
|
31
31
|
hqRoot: string,
|
|
32
32
|
locked: boolean,
|
|
@@ -101,9 +101,25 @@ export function registerSyncCommand(program: Command): void {
|
|
|
101
101
|
const results: SyncResult[] = [];
|
|
102
102
|
const newLock: ModuleLock = { version: '1', locked: {} };
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
// strategy: package entries are wired by scripts/scan-packages.sh, not
|
|
105
|
+
// the git-repo sync flow. Partition and report separately.
|
|
106
|
+
const legacyModules = manifest.modules.filter(
|
|
107
|
+
(m): m is LegacyModuleDefinition => m.strategy !== 'package'
|
|
108
|
+
);
|
|
109
|
+
const packModules = manifest.modules.filter(
|
|
110
|
+
(m) => m.strategy === 'package'
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (packModules.length > 0) {
|
|
114
|
+
console.log(
|
|
115
|
+
`Skipping ${packModules.length} package module(s) (wired via scan-packages.sh): ` +
|
|
116
|
+
packModules.map((m) => m.name).join(', ')
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(`Syncing ${legacyModules.length} module(s)...\n`);
|
|
105
121
|
|
|
106
|
-
for (const module of
|
|
122
|
+
for (const module of legacyModules) {
|
|
107
123
|
console.log(`[${module.name}]`);
|
|
108
124
|
const moduleDir = path.join(modulesDir, module.name);
|
|
109
125
|
|