@indigoai-us/hq-cli 5.345.169 → 5.345.171
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 +69 -0
- package/assets/scaffold/core/scripts/hq-delegate-send.sh +1 -1
- package/dist/command-catalog.generated.d.ts +181 -9
- package/dist/command-catalog.generated.js +224 -10
- package/dist/commands/access.d.ts +32 -1
- package/dist/commands/access.js +119 -5
- package/dist/commands/agent-kit.d.ts +1 -1
- package/dist/commands/agent-kit.js +3 -1
- package/dist/commands/agents.js +2 -1
- package/dist/commands/bot.d.ts +3 -0
- package/dist/commands/bot.js +141 -14
- package/dist/commands/cloud.d.ts +78 -1
- package/dist/commands/cloud.js +274 -9
- package/dist/commands/core-company-index-scope-flag.d.ts +8 -1
- package/dist/commands/core-company-index-scope-flag.js +9 -2
- package/dist/commands/core-delegate-w1b.js +5 -2
- package/dist/commands/core-dm-bind-native.js +19 -4
- package/dist/commands/core-rebuild-index-fast.js +24 -6
- package/dist/commands/core.js +18 -6
- package/dist/commands/dm.d.ts +17 -3
- package/dist/commands/dm.js +121 -26
- package/dist/commands/feedback-triage.d.ts +43 -1
- package/dist/commands/feedback-triage.js +123 -23
- package/dist/commands/feedback.d.ts +40 -1
- package/dist/commands/feedback.js +184 -4
- package/dist/commands/files.d.ts +42 -0
- package/dist/commands/files.js +223 -30
- package/dist/commands/integrations-api.d.ts +4 -0
- package/dist/commands/integrations-api.js +18 -2
- package/dist/commands/integrations-connect.d.ts +10 -3
- package/dist/commands/integrations-connect.js +70 -6
- package/dist/commands/integrations-core.d.ts +23 -0
- package/dist/commands/integrations-core.js +100 -0
- package/dist/commands/integrations-manage.js +8 -1
- package/dist/commands/integrations.js +49 -5
- package/dist/commands/lanes-loop.d.ts +20 -0
- package/dist/commands/lanes-loop.js +69 -11
- package/dist/commands/lanes.js +9 -1
- package/dist/commands/mesh.js +6 -0
- package/dist/commands/rescue.d.ts +9 -0
- package/dist/commands/rescue.js +31 -0
- package/dist/commands/run.js +115 -40
- package/dist/commands/scan.d.ts +5 -1
- package/dist/commands/scan.js +16 -4
- package/dist/commands/secrets.js +50 -9
- package/dist/commands/sync-manifest.d.ts +16 -0
- package/dist/commands/sync-manifest.js +17 -1
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2367 -2109
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/agent-kit/inbox-state.d.ts +17 -0
- package/dist/lib/agent-kit/inbox-state.js +41 -20
- package/dist/lib/agent-kit/kit-config.d.ts +2 -0
- package/dist/lib/agent-kit/kit-config.js +6 -0
- package/dist/lib/agent-kit/mcp/tools.js +12 -5
- package/dist/lib/agent-kit/run/heartbeat.d.ts +6 -3
- package/dist/lib/agent-kit/run/heartbeat.js +6 -3
- package/dist/lib/agent-kit/run/inbox.d.ts +8 -3
- package/dist/lib/agent-kit/run/inbox.js +42 -10
- package/dist/lib/agent-kit/run/request-timeout.d.ts +26 -0
- package/dist/lib/agent-kit/run/request-timeout.js +51 -0
- package/dist/lib/bot/api.d.ts +1 -1
- package/dist/lib/bot/config.d.ts +10 -0
- package/dist/lib/bot/config.js +17 -1
- package/dist/lib/bot/decisions.d.ts +62 -0
- package/dist/lib/bot/decisions.js +228 -0
- package/dist/lib/bot/fleet-local.d.ts +127 -0
- package/dist/lib/bot/fleet-local.js +638 -0
- package/dist/lib/bot/index.d.ts +2 -0
- package/dist/lib/bot/index.js +2 -0
- package/dist/lib/bot/local-config.d.ts +3 -2
- package/dist/lib/bot/local-config.js +5 -2
- package/dist/lib/bot/promote.d.ts +10 -0
- package/dist/lib/bot/promote.js +3 -2
- package/dist/lib/bot/run.d.ts +7 -0
- package/dist/lib/bot/run.js +104 -1
- package/dist/lib/bot/runtime/index.js +8 -4
- package/dist/lib/bot/runtime/scratch-dir.d.ts +24 -0
- package/dist/lib/bot/runtime/scratch-dir.js +41 -0
- package/dist/lib/caller-principal.d.ts +4 -0
- package/dist/lib/caller-principal.js +40 -0
- package/dist/lib/capacity/collectors/grok.js +8 -2
- package/dist/lib/cli-flag-keys.d.ts +2 -0
- package/dist/lib/cli-flag-keys.js +2 -0
- package/dist/lib/daemon/catalog.d.ts +4 -0
- package/dist/lib/daemon/catalog.js +63 -29
- package/dist/lib/daemon/config.d.ts +7 -0
- package/dist/lib/daemon/config.js +32 -0
- package/dist/lib/daemon/run.d.ts +3 -2
- package/dist/lib/daemon/run.js +23 -3
- package/dist/lib/daemon/supervisor.d.ts +1 -0
- package/dist/lib/daemon/supervisor.js +4 -0
- package/dist/lib/daemon/sync-core-canary.d.ts +12 -0
- package/dist/lib/daemon/sync-core-canary.js +61 -0
- package/dist/lib/doctor/checks/release-links.d.ts +62 -1
- package/dist/lib/doctor/checks/release-links.js +188 -2
- package/dist/lib/doctor/checks/sync-health.d.ts +32 -0
- package/dist/lib/doctor/checks/sync-health.js +54 -1
- package/dist/lib/doctor/fix/apply.d.ts +26 -4
- package/dist/lib/doctor/fix/apply.js +149 -11
- package/dist/lib/doctor/fix/remediation.d.ts +8 -1
- package/dist/lib/doctor/fix/remediation.js +22 -0
- package/dist/lib/doctor/types.d.ts +1 -1
- package/dist/lib/doctor/types.js +1 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/index-render/index.d.ts +9 -0
- package/dist/lib/index-render/index.js +65 -5
- package/dist/lib/index-render/session-scope.d.ts +69 -0
- package/dist/lib/index-render/session-scope.js +279 -0
- package/dist/lib/lanes/admission.js +7 -20
- package/dist/lib/lanes/reconcile.js +13 -15
- package/dist/lib/lanes/stale-lock.d.ts +50 -0
- package/dist/lib/lanes/stale-lock.js +105 -0
- package/dist/lib/lanes/store.js +11 -76
- package/dist/lib/mesh/live/daemon/work-subscribe.js +31 -0
- package/dist/lib/notify-recipient.d.ts +10 -4
- package/dist/lib/notify-recipient.js +16 -4
- package/dist/lib/secret-input-link.d.ts +42 -0
- package/dist/lib/secret-input-link.js +93 -0
- package/dist/lib/secret-scan/range.d.ts +57 -0
- package/dist/lib/secret-scan/range.js +266 -0
- package/dist/lib/secret-scan/repo-config.d.ts +18 -3
- package/dist/lib/secret-scan/repo-config.js +135 -2
- package/dist/lib/work-context/organize.d.ts +7 -0
- package/dist/lib/work-context/organize.js +21 -1
- package/dist/run/script-bound-schema.d.ts +27 -0
- package/dist/run/script-bound-schema.js +97 -0
- package/dist/secrets-env-action.d.ts +2 -0
- package/dist/secrets-env-action.js +7 -2
- package/dist/sentry-before-send.js +1 -0
- package/dist/utils/cognito-session.d.ts +3 -30
- package/dist/utils/cognito-session.js +2 -100
- package/dist/utils/machine-principal-state.d.ts +38 -0
- package/dist/utils/machine-principal-state.js +110 -0
- package/dist/utils/safe-link.js +9 -0
- package/dist/utils/script-bound-exec.d.ts +158 -0
- package/dist/utils/script-bound-exec.js +568 -0
- package/dist/utils/sync-watchdog.d.ts +92 -0
- package/dist/utils/sync-watchdog.js +250 -0
- package/package.json +8 -8
|
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { readLane, retryUpdateLane, updateLane } from "../lib/lanes/store.js";
|
|
5
5
|
import { LanesError, isLaneVersionConflict } from "../lib/lanes/errors.js";
|
|
6
|
+
import { liveNodeBinary } from "../lib/lanes/node-binary.js";
|
|
6
7
|
import { readLatestEnvelopeRecord } from "../lib/lanes/watcher.js";
|
|
7
8
|
import { spawnLaneWorker } from "../lib/lanes/spawn.js";
|
|
8
9
|
import { raiseLaneQuestion } from "../lib/lanes/questions.js";
|
|
@@ -33,6 +34,44 @@ export function settleLoopLaneIdle(hqRoot, laneId) {
|
|
|
33
34
|
export function prepareIdleLoopLaneForResume(hqRoot, laneId) {
|
|
34
35
|
return casLoopLaneState(hqRoot, laneId, "running", "done");
|
|
35
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* LanesError codes from the resume admission path that mean admission could
|
|
39
|
+
* not run right now and a later admission can retry: the admission or store
|
|
40
|
+
* lock timed out (store_lock_timeout), the lane changed while admission was
|
|
41
|
+
* checked (lane_admission_conflict), or a lane record CAS miss
|
|
42
|
+
* (version_conflict). Any other resume error is permanent and is rethrown.
|
|
43
|
+
*/
|
|
44
|
+
export const TRANSIENT_LOOP_ADMISSION_ERRORS = new Set([
|
|
45
|
+
"store_lock_timeout",
|
|
46
|
+
"lane_admission_conflict",
|
|
47
|
+
"version_conflict",
|
|
48
|
+
]);
|
|
49
|
+
/**
|
|
50
|
+
* Park a loop phase whose resume was refused before an engine launched. The
|
|
51
|
+
* lane becomes `queued` with `admit_failed` and the error, and carries the
|
|
52
|
+
* phase as `pending_resume`, so admission can launch it later and the lane is
|
|
53
|
+
* never left looking active with no engine.
|
|
54
|
+
*/
|
|
55
|
+
export function deferRefusedLoopPhase(hqRoot, laneId, at, error, phase) {
|
|
56
|
+
return retryUpdateLane(hqRoot, laneId, "command", (record) => ({
|
|
57
|
+
...record,
|
|
58
|
+
state: "queued",
|
|
59
|
+
queued_reason: "admit_failed",
|
|
60
|
+
queued_details: { reason: "admit_failed", error: error.error, message: error.message || error.error },
|
|
61
|
+
queued_at: at,
|
|
62
|
+
admission_reserved_at: undefined,
|
|
63
|
+
pending_resume: {
|
|
64
|
+
...(phase.sessionId ? { session_id: phase.sessionId } : { fresh_thread: true }),
|
|
65
|
+
trigger: "resume",
|
|
66
|
+
caller: "loop",
|
|
67
|
+
resumed_from: "done",
|
|
68
|
+
text: phase.text,
|
|
69
|
+
compact: true,
|
|
70
|
+
working_directory: phase.workingDirectory,
|
|
71
|
+
at,
|
|
72
|
+
},
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
36
75
|
function casLoopLaneState(hqRoot, laneId, from, to) {
|
|
37
76
|
for (let attempt = 0;; attempt += 1) {
|
|
38
77
|
const current = readLane(hqRoot, laneId);
|
|
@@ -67,8 +106,8 @@ export function startLaneLoopController(ctx, laneId, initialAdmission = false) {
|
|
|
67
106
|
const args = [entry, "lanes", "_loop-worker", laneId];
|
|
68
107
|
const env = { ...ctx.env, HQ_LANE_LOOP_CONTROLLER: "1", ...(initialAdmission ? { HQ_LANE_LOOP_INITIAL: "1" } : {}) };
|
|
69
108
|
const child = ctx.spawnProcess
|
|
70
|
-
? ctx.spawnProcess(
|
|
71
|
-
: spawn(
|
|
109
|
+
? ctx.spawnProcess(liveNodeBinary(), args, { cwd: ctx.hqRoot(), detached: true, windowsHide: true, stdio: ["ignore", 1, 2], env })
|
|
110
|
+
: spawn(liveNodeBinary(), args, { cwd: ctx.hqRoot(), detached: true, stdio: "ignore", env });
|
|
72
111
|
child.unref();
|
|
73
112
|
if (child.pid === undefined)
|
|
74
113
|
throw new LanesError("loop_controller_spawn_failed", "Loop controller did not start", { lane_id: laneId });
|
|
@@ -208,15 +247,34 @@ export async function runLaneLoopController(ctx, laneId, deps) {
|
|
|
208
247
|
else {
|
|
209
248
|
if (current.state === "running")
|
|
210
249
|
current = prepareIdleLoopLaneForResume(hqRoot, laneId);
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
250
|
+
const resumeText = `Read ${briefPath} and follow it as the complete instruction for this phase.`;
|
|
251
|
+
try {
|
|
252
|
+
await deps.resumeLane(laneId, {
|
|
253
|
+
text: resumeText,
|
|
254
|
+
project: current.project_id,
|
|
255
|
+
...(storyId ? { story: storyId } : {}),
|
|
256
|
+
freshThread: fresh,
|
|
257
|
+
...(sessionId ? { loopSessionId: sessionId } : {}),
|
|
258
|
+
...(repairError !== undefined ? { loopHandoffRepair: true } : {}),
|
|
259
|
+
workingDirectory: worktree,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
// Transient admission refusal before any engine launched (for
|
|
264
|
+
// example a store lock timeout). The envelope is already active, so keep it
|
|
265
|
+
// and park the lane as queued with a reason `hq lanes show`
|
|
266
|
+
// displays; the next admission launches this phase from
|
|
267
|
+
// pending_resume and the wait below picks up its result.
|
|
268
|
+
if (!(error instanceof LanesError) ||
|
|
269
|
+
!TRANSIENT_LOOP_ADMISSION_ERRORS.has(error.error) ||
|
|
270
|
+
readLane(hqRoot, laneId).state !== "done")
|
|
271
|
+
throw error;
|
|
272
|
+
deferRefusedLoopPhase(hqRoot, laneId, ctx.now().toISOString(), error, {
|
|
273
|
+
text: resumeText,
|
|
274
|
+
sessionId: fresh ? undefined : sessionId ?? current.provider_session_id,
|
|
275
|
+
workingDirectory: worktree,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
220
278
|
}
|
|
221
279
|
let interruptPromise;
|
|
222
280
|
const abortPhase = () => {
|
package/dist/commands/lanes.js
CHANGED
|
@@ -508,9 +508,17 @@ function livenessDepsFromCtx(ctx) {
|
|
|
508
508
|
* envelopes; every other lane spawns its worker directly. Every admission path
|
|
509
509
|
* (reconcile, in-process queue drain, hq lanes admit) goes through here so a
|
|
510
510
|
* loop lane never runs its brief without a controller.
|
|
511
|
+
*
|
|
512
|
+
* A queued loop lane whose controller is still alive is a deferred phase: the
|
|
513
|
+
* controller already activated the envelope and is waiting for its engine.
|
|
514
|
+
* That lane spawns the phase worker from pending_resume; starting a second
|
|
515
|
+
* controller would lose the runner lease and leave the lane running with no
|
|
516
|
+
* engine (feedback_865fe8a0).
|
|
511
517
|
*/
|
|
512
518
|
function startAdmittedLane(ctx, root, candidate) {
|
|
513
|
-
|
|
519
|
+
const deferredLoopPhase = candidate.loop_mode === true && candidate.pending_resume !== undefined &&
|
|
520
|
+
loopControllerIsAlive(runDir(root, candidate.lane_id));
|
|
521
|
+
if (candidate.loop_mode !== true || candidate.state !== "queued" || deferredLoopPhase) {
|
|
514
522
|
const admissionHost = reconcileHostFromCtx(ctx);
|
|
515
523
|
return spawnLaneWorker(root, candidate, {
|
|
516
524
|
env: ctx.env,
|
package/dist/commands/mesh.js
CHANGED
|
@@ -2070,11 +2070,16 @@ async function runContextOrganize(opts) {
|
|
|
2070
2070
|
!opts.untracked);
|
|
2071
2071
|
if (isList) {
|
|
2072
2072
|
let fetchCandidates;
|
|
2073
|
+
let resolveCompanyUid;
|
|
2073
2074
|
let offline = Boolean(opts.offline);
|
|
2074
2075
|
if (!offline) {
|
|
2075
2076
|
try {
|
|
2076
2077
|
const token = await requireToken();
|
|
2077
2078
|
fetchCandidates = createCandidatesFetcher({ token });
|
|
2079
|
+
resolveCompanyUid = async (slug) => {
|
|
2080
|
+
const membership = await resolveActiveMembershipCompany(token, slug, AbortSignal.timeout(ONLINE_MEMBERSHIP_VALIDATION_TIMEOUT_MS));
|
|
2081
|
+
return membership?.companyUid ?? null;
|
|
2082
|
+
};
|
|
2078
2083
|
}
|
|
2079
2084
|
catch {
|
|
2080
2085
|
offline = true;
|
|
@@ -2084,6 +2089,7 @@ async function runContextOrganize(opts) {
|
|
|
2084
2089
|
sessionId,
|
|
2085
2090
|
root,
|
|
2086
2091
|
fetchCandidates,
|
|
2092
|
+
resolveCompanyUid,
|
|
2087
2093
|
offline,
|
|
2088
2094
|
});
|
|
2089
2095
|
if (opts.json || opts.machine) {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { Command } from 'commander';
|
|
23
23
|
import { retrustCodexHooksAfterRescue } from '../utils/hook-trust.js';
|
|
24
|
+
import { repairDanglingReleaseLinks } from '../lib/doctor/checks/release-links.js';
|
|
24
25
|
export interface RescueTarget {
|
|
25
26
|
source: string;
|
|
26
27
|
/** undefined → let the script apply its own default ref (`main`). */
|
|
@@ -52,6 +53,14 @@ export declare function shouldAttemptSelfUpdate(opts: {
|
|
|
52
53
|
* changes the rescue's exit status, because the core update itself succeeded.
|
|
53
54
|
*/
|
|
54
55
|
export declare function retrustHooksAfterRescue(hqRoot: string, retrust?: typeof retrustCodexHooksAfterRescue): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Repair HQ-managed release links (`.agents/skills`, `.codex/claude`, ...)
|
|
58
|
+
* that do not resolve after a rescue, for example because they were laid at
|
|
59
|
+
* the rescue's temp extraction folder, which is then deleted
|
|
60
|
+
* (feedback_2ab63e50). Exported for tests. Best-effort: a failure is reported
|
|
61
|
+
* but never changes the rescue's exit status.
|
|
62
|
+
*/
|
|
63
|
+
export declare function repairReleaseLinksAfterRescue(hqRoot: string, repair?: typeof repairDanglingReleaseLinks): void;
|
|
55
64
|
/** Latest release tag for a repo, e.g. `v12.3.0`. Throws on failure. */
|
|
56
65
|
export declare function getLatestReleaseTag(repo: string, token?: string): Promise<string>;
|
|
57
66
|
/** Resolve a tag to its commit SHA, dereferencing annotated tags. */
|
package/dist/commands/rescue.js
CHANGED
|
@@ -6,6 +6,7 @@ import { rescue } from '@indigoai-us/hq-cloud';
|
|
|
6
6
|
import { findHqRoot } from '../utils/manifest.js';
|
|
7
7
|
import { selfUpdateAndReexec } from '../utils/self-update.js';
|
|
8
8
|
import { retrustCodexHooksAfterRescue } from '../utils/hook-trust.js';
|
|
9
|
+
import { repairDanglingReleaseLinks } from '../lib/doctor/checks/release-links.js';
|
|
9
10
|
import { PROD_SOURCE, STAGING_SOURCE, resolveGhToken } from '../utils/shipped-codex-hooks.js';
|
|
10
11
|
/**
|
|
11
12
|
* Resolve the source repo + ref from the user's flags. Pure + exported for
|
|
@@ -49,6 +50,35 @@ export async function retrustHooksAfterRescue(hqRoot, retrust = retrustCodexHook
|
|
|
49
50
|
'Codex will skip changed HQ hooks until you run `hq reindex`.'));
|
|
50
51
|
}
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Repair HQ-managed release links (`.agents/skills`, `.codex/claude`, ...)
|
|
55
|
+
* that do not resolve after a rescue, for example because they were laid at
|
|
56
|
+
* the rescue's temp extraction folder, which is then deleted
|
|
57
|
+
* (feedback_2ab63e50). Exported for tests. Best-effort: a failure is reported
|
|
58
|
+
* but never changes the rescue's exit status.
|
|
59
|
+
*/
|
|
60
|
+
export function repairReleaseLinksAfterRescue(hqRoot, repair = repairDanglingReleaseLinks) {
|
|
61
|
+
try {
|
|
62
|
+
for (const entry of repair(hqRoot)) {
|
|
63
|
+
if (entry.action === 'repointed') {
|
|
64
|
+
console.log(`rescue: repointed dangling link ${entry.rel} -> ${entry.linkText ?? entry.target}`);
|
|
65
|
+
}
|
|
66
|
+
else if (entry.action === 'removed') {
|
|
67
|
+
console.log(`rescue: removed dangling link ${entry.rel} (its target is missing)`);
|
|
68
|
+
}
|
|
69
|
+
else if (entry.action === 'skipped') {
|
|
70
|
+
console.log(`rescue: left dangling link ${entry.rel} alone: ${entry.reason ?? 'not an HQ-managed target'}`);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
console.warn(chalk.yellow(`rescue: could not repair ${entry.rel}: ${entry.reason ?? 'unknown error'}. Run \`hq doctor --fix\`.`));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
console.warn(chalk.yellow(`rescue: could not repair dangling release links: ${err instanceof Error ? err.message : 'unknown error'}. ` +
|
|
79
|
+
'Run `hq doctor --fix`.'));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
52
82
|
function ghHeaders(token) {
|
|
53
83
|
const headers = {
|
|
54
84
|
Accept: 'application/vnd.github+json',
|
|
@@ -185,6 +215,7 @@ export function registerRescueCommand(program) {
|
|
|
185
215
|
ghToken: token,
|
|
186
216
|
});
|
|
187
217
|
if (status === 0 && !opts.check) {
|
|
218
|
+
repairReleaseLinksAfterRescue(hqRoot);
|
|
188
219
|
await retrustHooksAfterRescue(hqRoot);
|
|
189
220
|
}
|
|
190
221
|
process.exit(status);
|
package/dist/commands/run.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { assertInjectedNamesAllowed, assertLiteralNamesAllowed, assertScriptUnchanged, planScriptBinding, selectChildLaunch } from '../utils/script-bound-exec.js';
|
|
2
|
+
import { parseStrictSchemaFiles } from '../run/script-bound-schema.js';
|
|
1
3
|
import { spawn } from 'node:child_process';
|
|
2
4
|
import * as path from 'node:path';
|
|
3
5
|
import * as fs from 'node:fs';
|
|
@@ -33,6 +35,47 @@ export async function buildRunUsage(scriptPath, scriptId) {
|
|
|
33
35
|
},
|
|
34
36
|
};
|
|
35
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* `hq run --script` resolution: read the schema files strictly, fetch only the
|
|
40
|
+
* hq() secrets, and return literals plus secret values. No varlock graph.
|
|
41
|
+
*/
|
|
42
|
+
async function resolveStrictSchema(paths, companyUid, usage, fetchBatch) {
|
|
43
|
+
const strict = parseStrictSchemaFiles(paths);
|
|
44
|
+
// Refuse proxy, CA/TLS, pager/editor, HOME/XDG and loader names before any
|
|
45
|
+
// fetch, so a schema literal cannot redirect what the bound script calls.
|
|
46
|
+
// Literals must be on the small --script allowlist; hq() names keep the
|
|
47
|
+
// denylist check (creating a secret needs vault write).
|
|
48
|
+
assertLiteralNamesAllowed(Object.keys(strict.statics));
|
|
49
|
+
assertInjectedNamesAllowed(strict.secrets.keys());
|
|
50
|
+
const names = [...new Set(strict.secrets.values())];
|
|
51
|
+
if (names.length > 100) {
|
|
52
|
+
throw new Error(`hq run supports at most 100 hq() resolvers per schema; got ${names.length}`);
|
|
53
|
+
}
|
|
54
|
+
const values = new Map();
|
|
55
|
+
if (names.length > 0) {
|
|
56
|
+
const data = await fetchBatch(companyUid, names, usage);
|
|
57
|
+
if (!Array.isArray(data?.secrets) || !Array.isArray(data?.errors)) {
|
|
58
|
+
throw new Error('Invalid secret load response from vault');
|
|
59
|
+
}
|
|
60
|
+
const firstError = data.errors[0];
|
|
61
|
+
if (firstError) {
|
|
62
|
+
throw new Error(`Failed to fetch secret '${firstError.name}': ${firstError.message ?? firstError.code}`);
|
|
63
|
+
}
|
|
64
|
+
for (const row of data.secrets) {
|
|
65
|
+
if (row && typeof row.name === 'string' && typeof row.value === 'string' && row.revokedAt == null) {
|
|
66
|
+
values.set(row.name, row.value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const env = { ...strict.statics };
|
|
71
|
+
for (const [key, name] of strict.secrets) {
|
|
72
|
+
const value = values.get(name);
|
|
73
|
+
if (value === undefined)
|
|
74
|
+
throw new Error(`Failed to fetch secret '${name}': not returned by vault`);
|
|
75
|
+
env[key] = value;
|
|
76
|
+
}
|
|
77
|
+
return env;
|
|
78
|
+
}
|
|
36
79
|
export function registerRunCommand(program) {
|
|
37
80
|
program
|
|
38
81
|
.command('run')
|
|
@@ -89,8 +132,19 @@ export function registerRunCommand(program) {
|
|
|
89
132
|
const uid = await getCompanyUid(token, slug, undefined, {
|
|
90
133
|
allowOfflineScopeFallback: true,
|
|
91
134
|
});
|
|
135
|
+
// feedback_0e6abcb8: with --script, script-locked secrets may reach
|
|
136
|
+
// only the approved script itself. Resolve the command now; a refusal
|
|
137
|
+
// is applied once the vault says which secrets are locked.
|
|
138
|
+
const scriptPlan = opts.check ? undefined : await planScriptBinding(opts.script, childArgs);
|
|
92
139
|
const usage = await buildRunUsage(opts.script, opts.scriptId);
|
|
140
|
+
if (scriptPlan?.bound) {
|
|
141
|
+
await assertScriptUnchanged(scriptPlan.bound, usage?.script?.sha256);
|
|
142
|
+
}
|
|
143
|
+
const requestedNames = new Set();
|
|
144
|
+
const lockModes = new Map();
|
|
93
145
|
const fetchBatch = async (companyUid, names, requestUsage) => withRunSecretReadTelemetry(companyUid, async () => {
|
|
146
|
+
for (const name of names)
|
|
147
|
+
requestedNames.add(name);
|
|
94
148
|
const res = await vaultApiFetch({
|
|
95
149
|
token,
|
|
96
150
|
path: `/secrets/${encodeURIComponent(companyUid)}/load`,
|
|
@@ -115,7 +169,12 @@ export function registerRunCommand(program) {
|
|
|
115
169
|
}
|
|
116
170
|
throw new Error(`Failed to batch-load secrets: ${message}`);
|
|
117
171
|
}
|
|
118
|
-
|
|
172
|
+
const data = await res.json();
|
|
173
|
+
for (const row of Array.isArray(data?.secrets) ? data.secrets : []) {
|
|
174
|
+
if (row && typeof row.name === 'string')
|
|
175
|
+
lockModes.set(row.name, row.scriptLock?.mode);
|
|
176
|
+
}
|
|
177
|
+
return data;
|
|
119
178
|
});
|
|
120
179
|
const pluginOpts = {
|
|
121
180
|
companyOverride: opts.company,
|
|
@@ -127,61 +186,77 @@ export function registerRunCommand(program) {
|
|
|
127
186
|
};
|
|
128
187
|
// LAST entry = highest precedence; .env.local files trail .env.schema files so any .env.local beats any schema regardless of depth.
|
|
129
188
|
const paths = [...schemaPaths, ...envLocalPaths];
|
|
130
|
-
let
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
skipCache: true,
|
|
138
|
-
afterInit: async (g) => { state = installHqPlugin(g, pluginOpts); },
|
|
139
|
-
});
|
|
140
|
-
// Refuse to resolve a graph varlock never finished loading (a malformed
|
|
141
|
-
// .env.schema / .env.local): resolving an unprocessed item whose key is
|
|
142
|
-
// also in process.env reaches varlock's `expected dataType to be set`
|
|
143
|
-
// invariant as a FLOATED, never-settling rejection — the CLI then exits
|
|
144
|
-
// 0 with the command never run (HQ-CLI-W). Run this BEFORE prewarm so a
|
|
145
|
-
// bad file can never trigger a secret fetch.
|
|
146
|
-
assertEnvGraphLoaded(graph);
|
|
147
|
-
await prewarmHqSecrets(graph, pluginOpts, state);
|
|
148
|
-
// Defence in depth: convert a floated resolution rejection into a thrown
|
|
149
|
-
// error instead of hanging on varlock's never-settling promise, in case
|
|
150
|
-
// the shape-dependent guard above ever silently degrades.
|
|
151
|
-
await resolveEnvValuesOrThrow(graph);
|
|
152
|
-
// varlock's item.errors includes warning-severity entries (isWarning),
|
|
153
|
-
// e.g. "Value is very short! Is it actually sensitive?" for a short
|
|
154
|
-
// sensitive value. Print those and carry on; only real errors are fatal.
|
|
155
|
-
const schemaItems = Object.entries(graph.configSchema);
|
|
156
|
-
const itemMessages = (wantWarnings) => schemaItems.flatMap(([k, item]) => (item.errors ?? [])
|
|
157
|
-
.filter((e) => Boolean(e?.isWarning) === wantWarnings)
|
|
158
|
-
.map((e) => ` ${k}: ${e?.message ?? String(e)}`));
|
|
159
|
-
const warningMsgs = itemMessages(true);
|
|
160
|
-
if (warningMsgs.length > 0) {
|
|
161
|
-
process.stderr.write(`Warning: env var checks reported warnings:\n${warningMsgs.join('\n')}\n`);
|
|
189
|
+
let resolvedEnv;
|
|
190
|
+
if (opts.script) {
|
|
191
|
+
// feedback_0e6abcb8: with --script, never build a varlock graph. Its
|
|
192
|
+
// exec()/plugin/import resolvers would run in this process while the
|
|
193
|
+
// fetched secrets are in memory, before the script binding is
|
|
194
|
+
// checked. The strict reader accepts only hq() items and literals.
|
|
195
|
+
resolvedEnv = await resolveStrictSchema(paths, uid, usage, fetchBatch);
|
|
162
196
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
197
|
+
else {
|
|
198
|
+
let state;
|
|
199
|
+
const graph = await internal.loadEnvGraph({
|
|
200
|
+
entryFilePaths: paths,
|
|
201
|
+
// hq run owns its short-lived in-memory resolution. Varlock's default
|
|
202
|
+
// cache auto-detection can write ~/.config/varlock and initialize
|
|
203
|
+
// the native local-encryption helper, neither of which belongs on
|
|
204
|
+
// this command path.
|
|
205
|
+
skipCache: true,
|
|
206
|
+
afterInit: async (g) => { state = installHqPlugin(g, pluginOpts); },
|
|
207
|
+
});
|
|
208
|
+
// Refuse to resolve a graph varlock never finished loading (a malformed
|
|
209
|
+
// .env.schema / .env.local): resolving an unprocessed item whose key is
|
|
210
|
+
// also in process.env reaches varlock's `expected dataType to be set`
|
|
211
|
+
// invariant as a FLOATED, never-settling rejection — the CLI then exits
|
|
212
|
+
// 0 with the command never run (HQ-CLI-W). Run this BEFORE prewarm so a
|
|
213
|
+
// bad file can never trigger a secret fetch.
|
|
214
|
+
assertEnvGraphLoaded(graph);
|
|
215
|
+
await prewarmHqSecrets(graph, pluginOpts, state);
|
|
216
|
+
// Defence in depth: convert a floated resolution rejection into a thrown
|
|
217
|
+
// error instead of hanging on varlock's never-settling promise, in case
|
|
218
|
+
// the shape-dependent guard above ever silently degrades.
|
|
219
|
+
await resolveEnvValuesOrThrow(graph);
|
|
220
|
+
// varlock's item.errors includes warning-severity entries (isWarning),
|
|
221
|
+
// e.g. "Value is very short! Is it actually sensitive?" for a short
|
|
222
|
+
// sensitive value. Print those and carry on; only real errors are fatal.
|
|
223
|
+
const schemaItems = Object.entries(graph.configSchema);
|
|
224
|
+
const itemMessages = (wantWarnings) => schemaItems.flatMap(([k, item]) => (item.errors ?? [])
|
|
225
|
+
.filter((e) => Boolean(e?.isWarning) === wantWarnings)
|
|
226
|
+
.map((e) => ` ${k}: ${e?.message ?? String(e)}`));
|
|
227
|
+
const warningMsgs = itemMessages(true);
|
|
228
|
+
if (warningMsgs.length > 0) {
|
|
229
|
+
process.stderr.write(`Warning: env var checks reported warnings:\n${warningMsgs.join('\n')}\n`);
|
|
230
|
+
}
|
|
231
|
+
const errorMsgs = itemMessages(false);
|
|
232
|
+
if (errorMsgs.length > 0) {
|
|
233
|
+
process.stderr.write(`Error: failed to resolve env vars:\n${errorMsgs.join('\n')}\n`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
resolvedEnv = graph.getResolvedEnvObject();
|
|
167
237
|
}
|
|
168
|
-
const resolvedEnv = graph.getResolvedEnvObject();
|
|
169
238
|
const varCount = Object.keys(resolvedEnv).length;
|
|
170
239
|
process.stderr.write(`Loaded ${varCount} env vars from .env.schema (company: ${slug})\n`);
|
|
171
240
|
if (opts.check) {
|
|
172
241
|
process.exit(0);
|
|
173
242
|
}
|
|
174
|
-
|
|
243
|
+
// Script-locked (or unknown) secrets go only to a private, re-hashed
|
|
244
|
+
// copy of the approved script; unlocked secrets keep the command as is.
|
|
245
|
+
const launch = selectChildLaunch(scriptPlan, [...requestedNames], lockModes, usage?.script?.sha256, childArgs, process.env, resolvedEnv);
|
|
246
|
+
const childCmd = launch.file;
|
|
247
|
+
const restArgs = launch.args;
|
|
175
248
|
const child = spawn(childCmd, restArgs, {
|
|
176
249
|
stdio: 'inherit',
|
|
177
|
-
env:
|
|
250
|
+
env: launch.env,
|
|
178
251
|
});
|
|
179
252
|
child.once('spawn', () => releaseChildWrapperPackageUseLeaseAfterStart("childWrapper", isHqCliChild(childCmd, restArgs)));
|
|
180
253
|
child.on('error', (err) => {
|
|
181
254
|
process.stderr.write(`Error: failed to start command '${childCmd}': ${err.message}\n`);
|
|
255
|
+
launch.cleanup();
|
|
182
256
|
process.exit(1);
|
|
183
257
|
});
|
|
184
258
|
child.on('close', (code, signal) => {
|
|
259
|
+
launch.cleanup();
|
|
185
260
|
if (signal) {
|
|
186
261
|
process.kill(process.pid, signal);
|
|
187
262
|
}
|
package/dist/commands/scan.d.ts
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* hq scan secrets --json findings as JSON
|
|
7
7
|
* hq scan secrets --repo <dir> [--snapshot-root <dir>] [--allowlist-rev <rev>] <file...>
|
|
8
8
|
* scan files with that repo's committed
|
|
9
|
-
* .gitleaks.toml allowlists
|
|
9
|
+
* .gitleaks.toml allowlists
|
|
10
|
+
* hq scan secrets --range <base>[..<head>] [--repo <dir>]
|
|
11
|
+
* scan the lines a branch added since <base>,
|
|
12
|
+
* every commit included (used by pr-check)
|
|
10
13
|
*
|
|
11
14
|
* Rules: the vendored gitleaks default rule set (MIT, vendor/gitleaks/) plus
|
|
12
15
|
* HQ's own token patterns. No network, no account, no quota. A staged scan also
|
|
@@ -24,6 +27,7 @@ interface ScanSecretsOptions {
|
|
|
24
27
|
repo?: string;
|
|
25
28
|
snapshotRoot?: string;
|
|
26
29
|
allowlistRev?: string;
|
|
30
|
+
range?: string;
|
|
27
31
|
}
|
|
28
32
|
export declare function runScanSecrets(paths: string[], opts: ScanSecretsOptions, cwd?: string): number;
|
|
29
33
|
export declare function registerScanCommand(program: Command): void;
|
package/dist/commands/scan.js
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* hq scan secrets --json findings as JSON
|
|
7
7
|
* hq scan secrets --repo <dir> [--snapshot-root <dir>] [--allowlist-rev <rev>] <file...>
|
|
8
8
|
* scan files with that repo's committed
|
|
9
|
-
* .gitleaks.toml allowlists
|
|
9
|
+
* .gitleaks.toml allowlists
|
|
10
|
+
* hq scan secrets --range <base>[..<head>] [--repo <dir>]
|
|
11
|
+
* scan the lines a branch added since <base>,
|
|
12
|
+
* every commit included (used by pr-check)
|
|
10
13
|
*
|
|
11
14
|
* Rules: the vendored gitleaks default rule set (MIT, vendor/gitleaks/) plus
|
|
12
15
|
* HQ's own token patterns. No network, no account, no quota. A staged scan also
|
|
@@ -18,6 +21,7 @@
|
|
|
18
21
|
* Output names the rule, file and line; secret values are never printed.
|
|
19
22
|
*/
|
|
20
23
|
import { ScanConfigError } from "../lib/secret-scan/repo-config.js";
|
|
24
|
+
import { scanRange } from "../lib/secret-scan/range.js";
|
|
21
25
|
import { ScanInputError, scanFiles, scanStaged } from "../lib/secret-scan/staged.js";
|
|
22
26
|
function report(findings, scope) {
|
|
23
27
|
if (!findings.length)
|
|
@@ -38,7 +42,14 @@ export function runScanSecrets(paths, opts, cwd = process.cwd()) {
|
|
|
38
42
|
throw new ScanInputError("--snapshot-root needs --repo");
|
|
39
43
|
if (opts.allowlistRev !== undefined && opts.repo === undefined)
|
|
40
44
|
throw new ScanInputError("--allowlist-rev needs --repo");
|
|
41
|
-
|
|
45
|
+
if (opts.range !== undefined) {
|
|
46
|
+
if (paths.length || opts.staged)
|
|
47
|
+
throw new ScanInputError("--range scans a commit range; do not pass files or --staged");
|
|
48
|
+
if (opts.snapshotRoot !== undefined || opts.allowlistRev !== undefined) {
|
|
49
|
+
throw new ScanInputError("--range reads .gitleaks.toml from the range base; drop --snapshot-root and --allowlist-rev");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
findings = opts.range !== undefined ? scanRange(opts.repo ?? cwd, opts.range) : paths.length && !opts.staged ? scanFiles(paths, { repo: opts.repo, snapshotRoot: opts.snapshotRoot, allowlistRev: opts.allowlistRev }) : scanStaged(cwd);
|
|
42
53
|
}
|
|
43
54
|
catch (err) {
|
|
44
55
|
const msg = err instanceof ScanInputError || err instanceof ScanConfigError ? err.message : `scan failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
@@ -51,7 +62,7 @@ export function runScanSecrets(paths, opts, cwd = process.cwd()) {
|
|
|
51
62
|
process.stdout.write(`${JSON.stringify({ ok: findings.length === 0, findings })}\n`);
|
|
52
63
|
}
|
|
53
64
|
else {
|
|
54
|
-
report(findings, paths.length && !opts.staged ? "the given files" : "staged changes");
|
|
65
|
+
report(findings, opts.range !== undefined ? `the lines added in ${opts.range}` : paths.length && !opts.staged ? "the given files" : "staged changes");
|
|
55
66
|
}
|
|
56
67
|
return findings.length ? 1 : 0;
|
|
57
68
|
}
|
|
@@ -63,9 +74,10 @@ export function registerScanCommand(program) {
|
|
|
63
74
|
.argument("[files...]", "Scan these files instead of the staged changes")
|
|
64
75
|
.option("--staged", "Scan the staged changes (default when no files are given)")
|
|
65
76
|
.option("--json", "Print findings as JSON")
|
|
66
|
-
.option("--repo <dir>", "With files: apply this repo's committed .gitleaks.toml allowlists, matching repo-relative paths")
|
|
77
|
+
.option("--repo <dir>", "With files: apply this repo's committed .gitleaks.toml allowlists, matching repo-relative paths. With --range: the repo to scan (default: the current directory)")
|
|
67
78
|
.option("--snapshot-root <dir>", "With --repo: files under <dir>/<commit>/<path> are matched as <path>")
|
|
68
79
|
.option("--allowlist-rev <rev>", "With --repo: read .gitleaks.toml from this commit instead of HEAD")
|
|
80
|
+
.option("--range <range>", "Scan the lines added in <base>..<head> (head defaults to HEAD), every commit included, with the base's .gitleaks.toml allowlists and the repo's [[rules]]")
|
|
69
81
|
.action((files, opts) => {
|
|
70
82
|
process.exitCode = runScanSecrets(files ?? [], opts);
|
|
71
83
|
});
|
package/dist/commands/secrets.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertScriptUnchanged, planScriptBinding, selectChildLaunch } from "../utils/script-bound-exec.js";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import * as yaml from "js-yaml";
|
|
@@ -77,6 +78,7 @@ import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, format
|
|
|
77
78
|
import { vaultApiFetch, getCompanyUid, getEntityUid, looksLikeCompanyUid, } from "../utils/vault-api.js";
|
|
78
79
|
import { formatApiKeyCapabilityDenial, peekHqApiKey, requireApiKeyCapability, resolveVaultCredentialForCapability, } from "../utils/resolve-vault-credential.js";
|
|
79
80
|
import { runSandboxJobAfterSubmission } from "../utils/secrets-sandbox-resident.js";
|
|
81
|
+
import { renderSecretInputLink, SECRET_LINK_FORMATS, } from "../lib/secret-input-link.js";
|
|
80
82
|
export { vaultApiFetch, getCompanyUid, getEntityUid };
|
|
81
83
|
/**
|
|
82
84
|
* Mirrors `RESOURCE_SURFACES` in hq-pro
|
|
@@ -1370,6 +1372,10 @@ export function registerSecretsCommand(program) {
|
|
|
1370
1372
|
process.exit(1);
|
|
1371
1373
|
}
|
|
1372
1374
|
const keys = parseSecretNameList(_opts.only);
|
|
1375
|
+
// feedback_0e6abcb8: with --script, a script-locked secret may reach
|
|
1376
|
+
// only the approved script itself. Resolve the command now; a refusal
|
|
1377
|
+
// is applied once the vault says which secrets are locked.
|
|
1378
|
+
const scriptPlan = await planScriptBinding(_opts.script, childArgs);
|
|
1373
1379
|
const parentScope = { ...secrets.opts() };
|
|
1374
1380
|
const inheritedCompany = parentScope.company;
|
|
1375
1381
|
let requestedCompany = _opts.company ?? inheritedCompany;
|
|
@@ -1466,9 +1472,14 @@ export function registerSecretsCommand(program) {
|
|
|
1466
1472
|
secret_names: keys,
|
|
1467
1473
|
});
|
|
1468
1474
|
}
|
|
1469
|
-
const
|
|
1475
|
+
const execUsage = cred.kind === "cognito"
|
|
1470
1476
|
? await buildSecretUsage("exec", _opts.script, _opts.scriptId)
|
|
1471
|
-
: undefined
|
|
1477
|
+
: undefined;
|
|
1478
|
+
if (scriptPlan?.bound) {
|
|
1479
|
+
await assertScriptUnchanged(scriptPlan.bound, execUsage?.script?.sha256);
|
|
1480
|
+
}
|
|
1481
|
+
const lockModes = new Map();
|
|
1482
|
+
const readSecrets = async () => loadRevealedSecrets(cred.token, companyUid, keys, execUsage, { allowOfflineFallback: true, lockModes });
|
|
1472
1483
|
// API-key auth has no signed-in person identity for the CLI telemetry
|
|
1473
1484
|
// endpoint. Do not attribute that read to an unrelated cached session.
|
|
1474
1485
|
let revealed;
|
|
@@ -1489,18 +1500,24 @@ export function registerSecretsCommand(program) {
|
|
|
1489
1500
|
}
|
|
1490
1501
|
secretEnv[key] = value;
|
|
1491
1502
|
}
|
|
1492
|
-
|
|
1503
|
+
// Script-locked (or unknown) secrets go only to a private, re-hashed
|
|
1504
|
+
// copy of the approved script; unlocked secrets keep the command as is.
|
|
1505
|
+
const launch = selectChildLaunch(scriptPlan, keys, lockModes, execUsage?.script?.sha256, childArgs, process.env, secretEnv);
|
|
1506
|
+
const childCmd = launch.file;
|
|
1507
|
+
const childCmdArgs = launch.args;
|
|
1493
1508
|
const { spawn } = await import("node:child_process");
|
|
1494
1509
|
const child = spawn(childCmd, childCmdArgs, {
|
|
1495
1510
|
stdio: "inherit",
|
|
1496
|
-
env:
|
|
1511
|
+
env: launch.env,
|
|
1497
1512
|
});
|
|
1498
1513
|
child.once("spawn", () => releaseChildWrapperPackageUseLeaseAfterStart("childWrapper", isHqCliChild(childCmd, childCmdArgs)));
|
|
1499
1514
|
child.on("error", (err) => {
|
|
1500
1515
|
console.error(chalk.red(`Failed to start command '${childCmd}': ${err.message}`));
|
|
1516
|
+
launch.cleanup();
|
|
1501
1517
|
process.exit(1);
|
|
1502
1518
|
});
|
|
1503
1519
|
child.on("close", (code, signal) => {
|
|
1520
|
+
launch.cleanup();
|
|
1504
1521
|
if (signal) {
|
|
1505
1522
|
process.kill(process.pid, signal);
|
|
1506
1523
|
}
|
|
@@ -1525,8 +1542,19 @@ export function registerSecretsCommand(program) {
|
|
|
1525
1542
|
.command("generate-link <name>")
|
|
1526
1543
|
.description("Generate a one-time link for someone to submit a secret value")
|
|
1527
1544
|
.option("--expires <duration>", "Token expiry duration (e.g. 24h, 2d, 30m)", "24h")
|
|
1545
|
+
.option("--format <format>", "Output format: text (default; OSC-8 hyperlink on a supporting TTY, plain URL otherwise; FORCE_HYPERLINK=1/0 overrides detection on a TTY only), markdown (one Markdown link line on stdout, details on stderr), json")
|
|
1546
|
+
.option("--json", "Print { url, expiresAt, secretName } as JSON (same as --format json)")
|
|
1528
1547
|
.action(async (name, opts) => {
|
|
1529
1548
|
try {
|
|
1549
|
+
const format = (opts.json ? "json" : (opts.format ?? "text"));
|
|
1550
|
+
if (!SECRET_LINK_FORMATS.includes(format)) {
|
|
1551
|
+
console.error(chalk.red(`Invalid format '${opts.format}': must be one of ${SECRET_LINK_FORMATS.join(", ")}`));
|
|
1552
|
+
process.exit(1);
|
|
1553
|
+
}
|
|
1554
|
+
if (opts.json && opts.format !== undefined && opts.format !== "json") {
|
|
1555
|
+
console.error(chalk.red(`--json conflicts with --format ${opts.format}`));
|
|
1556
|
+
process.exit(1);
|
|
1557
|
+
}
|
|
1530
1558
|
if (!SECRET_NAME_PATTERN.test(name)) {
|
|
1531
1559
|
console.error(chalk.red(`Invalid secret name '${name}': must match ^[A-Z][A-Z0-9_]*(/[A-Z][A-Z0-9_]+)*$ (e.g. MY_API_KEY or DEV/MY_KEY)`));
|
|
1532
1560
|
process.exit(1);
|
|
@@ -1556,11 +1584,24 @@ export function registerSecretsCommand(program) {
|
|
|
1556
1584
|
process.exit(1);
|
|
1557
1585
|
}
|
|
1558
1586
|
const data = (await res.json());
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1587
|
+
const rendered = renderSecretInputLink(data, {
|
|
1588
|
+
format,
|
|
1589
|
+
isTTY: process.stdout.isTTY === true,
|
|
1590
|
+
env: process.env,
|
|
1591
|
+
});
|
|
1592
|
+
if (format === "text") {
|
|
1593
|
+
const [heading, link, ...meta] = rendered.stdout;
|
|
1594
|
+
console.log(chalk.green(heading));
|
|
1595
|
+
console.log(link);
|
|
1596
|
+
for (const line of meta)
|
|
1597
|
+
console.log(chalk.dim(line));
|
|
1598
|
+
}
|
|
1599
|
+
else {
|
|
1600
|
+
for (const line of rendered.stdout)
|
|
1601
|
+
console.log(line);
|
|
1602
|
+
for (const line of rendered.stderr)
|
|
1603
|
+
console.error(chalk.dim(line));
|
|
1604
|
+
}
|
|
1564
1605
|
}
|
|
1565
1606
|
catch (err) {
|
|
1566
1607
|
// Same shared plan-limit renderer as `secrets set` (see main.ts).
|