@indigoai-us/hq-cli 5.345.44 → 5.345.45
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 +9 -0
- package/dist/command-catalog.generated.d.ts +17 -1
- package/dist/command-catalog.generated.js +21 -1
- package/dist/commands/access.js +6 -4
- package/dist/commands/bot.js +1 -1
- package/dist/commands/db-destroy.js +5 -4
- package/dist/commands/lanes-message.js +84 -51
- package/dist/commands/lanes-resume.js +44 -0
- package/dist/commands/lanes-shared.d.ts +25 -0
- package/dist/commands/lanes.d.ts +4 -0
- package/dist/commands/lanes.js +213 -4
- package/dist/commands/meetings.js +4 -1
- package/dist/lib/flag-snapshot-file.js +33 -25
- package/dist/lib/lanes/flag-keys.d.ts +1 -0
- package/dist/lib/lanes/flag-keys.js +1 -0
- package/dist/lib/lanes/focus-guard.d.ts +65 -0
- package/dist/lib/lanes/focus-guard.js +91 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
- package/dist/lib/lanes/outpost-dispatch.js +1 -0
- package/dist/lib/lanes/store.js +29 -0
- package/dist/lib/lanes/story-ledger.d.ts +14 -0
- package/dist/lib/lanes/story-ledger.js +46 -0
- package/dist/lib/lanes/types.d.ts +10 -2
- package/dist/lib/lanes/types.js +4 -3
- package/dist/utils/package-use-lease.d.ts +1 -0
- package/dist/utils/package-use-lease.js +29 -0
- package/dist/utils/self-update-backoff.d.ts +17 -0
- package/dist/utils/self-update-backoff.js +73 -0
- package/dist/utils/self-update.d.ts +7 -0
- package/dist/utils/self-update.js +36 -0
- package/dist/utils/vault-api.d.ts +33 -1
- package/dist/utils/vault-api.js +55 -5
- package/dist/utils/version-gate.d.ts +13 -0
- package/dist/utils/version-gate.js +35 -5
- package/package.json +1 -1
|
@@ -24,6 +24,20 @@ export interface StoryLedgerQuestionRef {
|
|
|
24
24
|
project_id: string;
|
|
25
25
|
story_id: string;
|
|
26
26
|
}
|
|
27
|
+
export interface ProjectFocusOverride {
|
|
28
|
+
reason: string;
|
|
29
|
+
by_lane: string;
|
|
30
|
+
at: string;
|
|
31
|
+
story: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function readProjectStoryFocusData(hqRoot: string, projectId: string, companySlug?: string): {
|
|
34
|
+
path: string;
|
|
35
|
+
metadata: Record<string, unknown>;
|
|
36
|
+
userStories: Array<Record<string, unknown>>;
|
|
37
|
+
focusOverrides: ProjectFocusOverride[];
|
|
38
|
+
};
|
|
39
|
+
export declare function appendProjectFocusOverride(hqRoot: string, projectId: string, companySlug: string | undefined, override: ProjectFocusOverride): void;
|
|
40
|
+
export declare function removeProjectFocusOverride(hqRoot: string, projectId: string, companySlug: string | undefined, override: ProjectFocusOverride): void;
|
|
27
41
|
export interface LaneStatusSnapshot {
|
|
28
42
|
schema_version: "hq-lanes-status.v1";
|
|
29
43
|
project_id: string;
|
|
@@ -11,6 +11,52 @@ const LOCK_RETRY_MS = 10;
|
|
|
11
11
|
const STORY_STATES = ["todo", "in_progress", "review", "waiting", "blocked", "done", "dropped"];
|
|
12
12
|
const MANAGER_OWNED_STATES = new Set(["review", "done", "dropped"]);
|
|
13
13
|
const TERMINAL_STATES = new Set(["done", "failed", "interrupted", "cancelled", "killed"]);
|
|
14
|
+
function projectFocusOverrides(value) {
|
|
15
|
+
if (value === undefined)
|
|
16
|
+
return [];
|
|
17
|
+
if (!Array.isArray(value) || value.length > 100) {
|
|
18
|
+
throw new LanesError("project_focus_overrides_invalid", "Project focus_overrides must be an array with at most 100 entries", {});
|
|
19
|
+
}
|
|
20
|
+
return value.map((item, index) => {
|
|
21
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
22
|
+
throw new LanesError("project_focus_overrides_invalid", `Project focus override ${index} must be an object`, {});
|
|
23
|
+
}
|
|
24
|
+
const entry = item;
|
|
25
|
+
if (["reason", "by_lane", "at", "story"].some((key) => typeof entry[key] !== "string" || !entry[key].trim())) {
|
|
26
|
+
throw new LanesError("project_focus_overrides_invalid", `Project focus override ${index} is incomplete`, {});
|
|
27
|
+
}
|
|
28
|
+
if (!Number.isFinite(Date.parse(entry.at))) {
|
|
29
|
+
throw new LanesError("project_focus_overrides_invalid", `Project focus override ${index} has an invalid timestamp`, {});
|
|
30
|
+
}
|
|
31
|
+
return entry;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function readProjectStoryFocusData(hqRoot, projectId, companySlug) {
|
|
35
|
+
const ledger = resolveProjectStoryLedger(hqRoot, projectId, companySlug);
|
|
36
|
+
const parsed = readLedger(ledger);
|
|
37
|
+
const metadata = parsed.metadata;
|
|
38
|
+
return {
|
|
39
|
+
path: ledger.path,
|
|
40
|
+
metadata: metadata !== null && typeof metadata === "object" && !Array.isArray(metadata)
|
|
41
|
+
? metadata
|
|
42
|
+
: {},
|
|
43
|
+
userStories: parsed.userStories,
|
|
44
|
+
focusOverrides: projectFocusOverrides(parsed.focus_overrides),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function appendProjectFocusOverride(hqRoot, projectId, companySlug, override) {
|
|
48
|
+
updateLedgerAtomically(resolveProjectStoryLedger(hqRoot, projectId, companySlug), (current) => {
|
|
49
|
+
const existing = projectFocusOverrides(current.focus_overrides);
|
|
50
|
+
return { ...current, focus_overrides: [...existing, override].slice(-100) };
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function removeProjectFocusOverride(hqRoot, projectId, companySlug, override) {
|
|
54
|
+
updateLedgerAtomically(resolveProjectStoryLedger(hqRoot, projectId, companySlug), (current) => {
|
|
55
|
+
const remaining = projectFocusOverrides(current.focus_overrides).filter((entry) => entry.reason !== override.reason || entry.by_lane !== override.by_lane ||
|
|
56
|
+
entry.at !== override.at || entry.story !== override.story);
|
|
57
|
+
return { ...current, focus_overrides: remaining };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
14
60
|
export function laneStoryLedgerEnabled(companyUid) {
|
|
15
61
|
return resolveProcessFlagGate(LANE_STORY_LEDGER_FLAG, { fallback: false }, () => false, companyUid);
|
|
16
62
|
}
|
|
@@ -525,6 +525,13 @@ export interface LaneRecord {
|
|
|
525
525
|
story_id: string;
|
|
526
526
|
/** Explicit reason a lane was created without a project story. */
|
|
527
527
|
no_story_reason?: string;
|
|
528
|
+
/** Human-authored focus guard overrides used to launch this lane onto a story. */
|
|
529
|
+
focus_overrides?: Array<{
|
|
530
|
+
reason: string;
|
|
531
|
+
by_lane: string;
|
|
532
|
+
at: string;
|
|
533
|
+
story: string;
|
|
534
|
+
}>;
|
|
528
535
|
/** Last failed story-ledger synchronization, cleared after a successful retry. */
|
|
529
536
|
ledger_sync_error?: string;
|
|
530
537
|
/** Pending question ids owned by this lane. */
|
|
@@ -758,8 +765,9 @@ export interface LaneRecord {
|
|
|
758
765
|
/** Last successful lane_progress mesh delivery, preserved across watcher restarts. */
|
|
759
766
|
last_progress_event_at?: string;
|
|
760
767
|
}
|
|
761
|
-
/** The v1 fields that must exist on every durable lane record (spec §3).
|
|
762
|
-
|
|
768
|
+
/** The v1 fields that must exist on every durable lane record (spec §3).
|
|
769
|
+
* `company` is normalized from absence for pre-attribution legacy records. */
|
|
770
|
+
export declare const REQUIRED_LANE_RECORD_FIELDS: readonly ["schema_version", "lane_id", "project_id", "story_id", "depth", "senior", "senior_history", "state", "priority", "provider", "host", "version", "created_at", "updated_at", "attribution_history"];
|
|
763
771
|
export declare const LANE_WRITERS: readonly ["creator", "command", "watcher", "reconcile", "spawn", "story-ledger"];
|
|
764
772
|
export type LaneWriter = (typeof LANE_WRITERS)[number];
|
|
765
773
|
/** Field ownership at the durable-record mutation boundary (spec §10.1). */
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -215,9 +215,10 @@ export function parseSeniorKind(value) {
|
|
|
215
215
|
return value;
|
|
216
216
|
throw new LanesError("unknown_senior_kind", `Unknown senior kind ${JSON.stringify(value)}. Accepted: ${SENIOR_KINDS.join(", ")}`, { accepted: [...SENIOR_KINDS], value });
|
|
217
217
|
}
|
|
218
|
-
/** The v1 fields that must exist on every durable lane record (spec §3).
|
|
218
|
+
/** The v1 fields that must exist on every durable lane record (spec §3).
|
|
219
|
+
* `company` is normalized from absence for pre-attribution legacy records. */
|
|
219
220
|
export const REQUIRED_LANE_RECORD_FIELDS = [
|
|
220
|
-
"schema_version", "lane_id", "
|
|
221
|
+
"schema_version", "lane_id", "project_id", "story_id", "depth",
|
|
221
222
|
"senior", "senior_history", "state", "priority", "provider", "host",
|
|
222
223
|
"version", "created_at", "updated_at", "attribution_history",
|
|
223
224
|
];
|
|
@@ -225,7 +226,7 @@ export const LANE_WRITERS = ["creator", "command", "watcher", "reconcile", "spaw
|
|
|
225
226
|
/** Field ownership at the durable-record mutation boundary (spec §10.1). */
|
|
226
227
|
export const LANE_FIELD_WRITERS = {
|
|
227
228
|
schema_version: ["creator"], lane_id: ["creator"], written_by_version: [], owner_written_by_version: [], company: ["creator", "command"], company_history: ["command"],
|
|
228
|
-
project_id: ["command"], story_id: ["command"], no_story_reason: ["creator", "command"], ledger_sync_error: ["story-ledger"], pending_question_ids: ["story-ledger"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator", "command"],
|
|
229
|
+
project_id: ["command"], story_id: ["command"], no_story_reason: ["creator", "command"], focus_overrides: ["command"], ledger_sync_error: ["story-ledger"], pending_question_ids: ["story-ledger"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator", "command"],
|
|
229
230
|
depth: ["command"], senior: ["command"], senior_history: ["command"],
|
|
230
231
|
hire_grant: ["creator", "command"],
|
|
231
232
|
released_hire_grant: ["command"],
|
|
@@ -48,6 +48,7 @@ export declare function currentPackageUseUpdatePending(): boolean;
|
|
|
48
48
|
export declare function currentPackageUseUpdateRequested(): boolean;
|
|
49
49
|
export declare const __test__: {
|
|
50
50
|
waitForUpdateRequest: typeof waitForUpdateRequest;
|
|
51
|
+
setPackageUseLeaseHeartbeatIntervalMs(intervalMs: number): void;
|
|
51
52
|
setCurrentPackageUseLease(lease: PackageUseLease | null): void;
|
|
52
53
|
};
|
|
53
54
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { utimes } from "node:fs/promises";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
@@ -8,6 +9,8 @@ const CLI_NAME = "@indigoai-us/hq-cli";
|
|
|
8
9
|
const UPDATE_REQUEST = "update.pending.json";
|
|
9
10
|
export const PACKAGE_USE_LEASE_HANDOFF_ENV = "HQ_CLI_PACKAGE_USE_LEASE_HANDOFF";
|
|
10
11
|
const HANDOFF_TIMEOUT_MS = 2_000;
|
|
12
|
+
const PACKAGE_USE_LEASE_HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;
|
|
13
|
+
let packageUseLeaseHeartbeatIntervalMs = PACKAGE_USE_LEASE_HEARTBEAT_INTERVAL_MS;
|
|
11
14
|
export const PACKAGE_USE_LEASE_PURPOSES = [
|
|
12
15
|
"command",
|
|
13
16
|
"resident-preload",
|
|
@@ -180,10 +183,32 @@ export function acquirePackageUseLease(prefix, hqVersion, stateDirectory, purpos
|
|
|
180
183
|
const leasePath = path.join(directory, `${record.pid}-${record.start_time_ms}.json`);
|
|
181
184
|
const tempPath = `${leasePath}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
182
185
|
let released = false;
|
|
186
|
+
let heartbeatInFlight = false;
|
|
187
|
+
let heartbeat = null;
|
|
188
|
+
const startHeartbeat = () => {
|
|
189
|
+
heartbeat = setInterval(() => {
|
|
190
|
+
if (released || heartbeatInFlight)
|
|
191
|
+
return;
|
|
192
|
+
heartbeatInFlight = true;
|
|
193
|
+
const now = new Date();
|
|
194
|
+
void utimes(leasePath, now, now)
|
|
195
|
+
.catch((error) => {
|
|
196
|
+
if (!released) {
|
|
197
|
+
process.stderr.write(`hq-cli package-use lease heartbeat failed (${errorLabel(error)})\n`);
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
.finally(() => {
|
|
201
|
+
heartbeatInFlight = false;
|
|
202
|
+
});
|
|
203
|
+
}, packageUseLeaseHeartbeatIntervalMs);
|
|
204
|
+
heartbeat.unref();
|
|
205
|
+
};
|
|
183
206
|
const release = () => {
|
|
184
207
|
if (released)
|
|
185
208
|
return;
|
|
186
209
|
released = true;
|
|
210
|
+
if (heartbeat)
|
|
211
|
+
clearInterval(heartbeat);
|
|
187
212
|
process.removeListener("exit", releaseOnExit);
|
|
188
213
|
try {
|
|
189
214
|
rmSync(leasePath, { force: true });
|
|
@@ -204,6 +229,7 @@ export function acquirePackageUseLease(prefix, hqVersion, stateDirectory, purpos
|
|
|
204
229
|
throw new Error(`Unable to publish the HQ CLI package-use lease (${errorLabel(error)})`, { cause: error });
|
|
205
230
|
}
|
|
206
231
|
if (!exists(requestPath)) {
|
|
232
|
+
startHeartbeat();
|
|
207
233
|
process.once("exit", releaseOnExit);
|
|
208
234
|
return { leasePath, release };
|
|
209
235
|
}
|
|
@@ -409,6 +435,9 @@ export function currentPackageUseUpdateRequested() {
|
|
|
409
435
|
}
|
|
410
436
|
export const __test__ = {
|
|
411
437
|
waitForUpdateRequest,
|
|
438
|
+
setPackageUseLeaseHeartbeatIntervalMs(intervalMs) {
|
|
439
|
+
packageUseLeaseHeartbeatIntervalMs = intervalMs;
|
|
440
|
+
},
|
|
412
441
|
setCurrentPackageUseLease(lease) {
|
|
413
442
|
currentPackageUseLease = lease;
|
|
414
443
|
},
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const SELF_UPDATE_FAILURE_BACKOFF_MS: number;
|
|
2
|
+
export interface SelfUpdateFailureRecord {
|
|
3
|
+
/** Version the failed attempt tried to install. */
|
|
4
|
+
target: string;
|
|
5
|
+
/** Epoch milliseconds of the failed attempt. */
|
|
6
|
+
failedAt: number;
|
|
7
|
+
}
|
|
8
|
+
export interface SelfUpdateBackoff {
|
|
9
|
+
/** The recorded failure when it is still inside the backoff window, else null. */
|
|
10
|
+
active(now?: number): SelfUpdateFailureRecord | null;
|
|
11
|
+
record(target: string, now?: number): void;
|
|
12
|
+
clear(): void;
|
|
13
|
+
}
|
|
14
|
+
export declare function selfUpdateBackoffPath(homeDir?: string): string;
|
|
15
|
+
/** File-backed backoff under `~/.hq`. Every operation is best-effort. */
|
|
16
|
+
export declare function fileSelfUpdateBackoff(homeDir?: string): SelfUpdateBackoff;
|
|
17
|
+
//# sourceMappingURL=self-update-backoff.d.ts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-target backoff for automatic CLI self-updates.
|
|
3
|
+
*
|
|
4
|
+
* The older loop guard in `version-check.ts` (`markLatestIneffective`) only
|
|
5
|
+
* suppresses the exact npm `latest` that failed, and only when the version
|
|
6
|
+
* cache file exists and still names that version. HQ publishes several
|
|
7
|
+
* releases a day, and the hq-pro "update recommended" path hands the updater a
|
|
8
|
+
* version that need not match the cache. Either way the next invocation saw a
|
|
9
|
+
* "new" target and ran the same failing install again, so a broken package
|
|
10
|
+
* manager made every `hq` command retry the update.
|
|
11
|
+
*
|
|
12
|
+
* This record is keyed on the failure itself, not on the target version: after
|
|
13
|
+
* any failed or ineffective automatic update, automatic updates pause for
|
|
14
|
+
* {@link SELF_UPDATE_FAILURE_BACKOFF_MS} whatever version is newest. An explicit
|
|
15
|
+
* `hq rescue` ignores the record and clears it when it succeeds.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as os from "node:os";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
export const SELF_UPDATE_FAILURE_BACKOFF_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
export function selfUpdateBackoffPath(homeDir = os.homedir()) {
|
|
22
|
+
return path.join(homeDir, ".hq", "self-update-backoff.json");
|
|
23
|
+
}
|
|
24
|
+
function readRecord(file) {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
27
|
+
if (typeof parsed.target !== "string" || typeof parsed.failedAt !== "number")
|
|
28
|
+
return null;
|
|
29
|
+
if (!Number.isFinite(parsed.failedAt))
|
|
30
|
+
return null;
|
|
31
|
+
return { target: parsed.target, failedAt: parsed.failedAt };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** File-backed backoff under `~/.hq`. Every operation is best-effort. */
|
|
38
|
+
export function fileSelfUpdateBackoff(homeDir) {
|
|
39
|
+
const file = selfUpdateBackoffPath(homeDir);
|
|
40
|
+
return {
|
|
41
|
+
active(now = Date.now()) {
|
|
42
|
+
const record = readRecord(file);
|
|
43
|
+
if (!record)
|
|
44
|
+
return null;
|
|
45
|
+
const age = now - record.failedAt;
|
|
46
|
+
// A record from the future (clock moved back) is treated as fresh only
|
|
47
|
+
// within one window, so a bad clock cannot disable updates forever.
|
|
48
|
+
if (age < -SELF_UPDATE_FAILURE_BACKOFF_MS)
|
|
49
|
+
return null;
|
|
50
|
+
return age <= SELF_UPDATE_FAILURE_BACKOFF_MS ? record : null;
|
|
51
|
+
},
|
|
52
|
+
record(target, now = Date.now()) {
|
|
53
|
+
try {
|
|
54
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
55
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
56
|
+
fs.writeFileSync(tmp, `${JSON.stringify({ target, failedAt: now })}\n`);
|
|
57
|
+
fs.renameSync(tmp, file);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// A write failure only means the next invocation may retry once.
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
clear() {
|
|
64
|
+
try {
|
|
65
|
+
fs.rmSync(file, { force: true });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Best-effort.
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=self-update-backoff.js.map
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
import { spawn } from "node:child_process";
|
|
60
60
|
import * as fs from "node:fs";
|
|
61
61
|
import { probePnpmVersion, type RunningInstall, type UpdateResult } from "./version-gate.js";
|
|
62
|
+
import { type SelfUpdateBackoff } from "./self-update-backoff.js";
|
|
62
63
|
import { type RestartDaemonResult } from "../lib/mesh/live/daemon/install.js";
|
|
63
64
|
/**
|
|
64
65
|
* Set on the re-exec'd child so it can never self-update (and re-exec) again.
|
|
@@ -167,6 +168,12 @@ export interface SelfUpdateDeps {
|
|
|
167
168
|
* stops auto-retrying it. Defaults to {@link markLatestIneffective}.
|
|
168
169
|
*/
|
|
169
170
|
markIneffective?: (version: string) => void;
|
|
171
|
+
/**
|
|
172
|
+
* Cross-target failure backoff. After a failed or ineffective automatic
|
|
173
|
+
* update, automatic updates pause for 24 hours. Defaults to a record under
|
|
174
|
+
* `~/.hq` (see `self-update-backoff.ts`).
|
|
175
|
+
*/
|
|
176
|
+
backoff?: SelfUpdateBackoff;
|
|
170
177
|
/**
|
|
171
178
|
* Whether a human is watching this invocation. Defaults to "stderr is a TTY",
|
|
172
179
|
* which is false for exactly the callers that must not replace the CLI
|
|
@@ -69,6 +69,7 @@ import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, bu
|
|
|
69
69
|
import { canStageInstall, stagedNpmInstall } from "./staged-install.js";
|
|
70
70
|
import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
|
|
71
71
|
import { markLatestIneffective } from "./version-check.js";
|
|
72
|
+
import { fileSelfUpdateBackoff } from "./self-update-backoff.js";
|
|
72
73
|
import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install.js";
|
|
73
74
|
import { createCurrentPackageUseLeaseHandoff, PACKAGE_USE_LEASE_HANDOFF_ENV, releaseCurrentPackageUseLease, } from "./package-use-lease.js";
|
|
74
75
|
/**
|
|
@@ -847,9 +848,44 @@ function sleepForRetry(ms) {
|
|
|
847
848
|
}
|
|
848
849
|
async function updateAndReexec(argv, flavor, known, deps) {
|
|
849
850
|
const env = deps.env ?? process.env;
|
|
851
|
+
// Recursion guard: a process started by an update (the re-exec'd command, a
|
|
852
|
+
// package-manager lifecycle script, or anything those spawn) carries this
|
|
853
|
+
// marker and must never start another update.
|
|
850
854
|
if (env[REEXEC_GUARD_ENV] === "1" || env.HQ_NO_UPDATE_CHECK === "1") {
|
|
851
855
|
return { action: "skipped" };
|
|
852
856
|
}
|
|
857
|
+
// A failed automatic update pauses automatic updates for every target, not
|
|
858
|
+
// just the one that failed: HQ ships several releases a day, so a target-keyed
|
|
859
|
+
// guard let each new release re-run the same broken install on every
|
|
860
|
+
// command. `hq rescue` is an explicit request and ignores the backoff.
|
|
861
|
+
const backoff = deps.backoff ?? fileSelfUpdateBackoff(deps.homeDir);
|
|
862
|
+
const explicit = flavor.noun === "rescue";
|
|
863
|
+
if (!explicit && backoff.active()) {
|
|
864
|
+
return { action: "skipped" };
|
|
865
|
+
}
|
|
866
|
+
let outcome;
|
|
867
|
+
try {
|
|
868
|
+
outcome = await attemptUpdateAndReexec(argv, flavor, known, deps, env);
|
|
869
|
+
}
|
|
870
|
+
catch (error) {
|
|
871
|
+
if (known)
|
|
872
|
+
backoff.record(known);
|
|
873
|
+
else
|
|
874
|
+
backoff.record("unknown");
|
|
875
|
+
throw error;
|
|
876
|
+
}
|
|
877
|
+
if (outcome.action === "update-failed" || outcome.action === "update-ineffective") {
|
|
878
|
+
backoff.record(outcome.latest ?? known ?? "unknown");
|
|
879
|
+
}
|
|
880
|
+
else if (outcome.action === "updated" ||
|
|
881
|
+
outcome.action === "reexec" ||
|
|
882
|
+
outcome.action === "updated-no-reexec" ||
|
|
883
|
+
outcome.action === "current") {
|
|
884
|
+
backoff.clear();
|
|
885
|
+
}
|
|
886
|
+
return outcome;
|
|
887
|
+
}
|
|
888
|
+
async function attemptUpdateAndReexec(argv, flavor, known, deps, env) {
|
|
853
889
|
const latest = known ?? (await (deps.fetchLatest ?? fetchLatestVersion)());
|
|
854
890
|
if (!latest)
|
|
855
891
|
return { action: "skipped" };
|
|
@@ -77,7 +77,39 @@ export declare function getCompanyUid(token: string, companySlug: string | undef
|
|
|
77
77
|
baseUrl?: string, options?: {
|
|
78
78
|
allowOfflineScopeFallback?: boolean;
|
|
79
79
|
}): Promise<string>;
|
|
80
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The `agt_*` uid an agent machine token names, or null for any other token.
|
|
82
|
+
* An agent ID token carries `custom:entityType=agent` and
|
|
83
|
+
* `custom:entityUid=agt_*`; a person token carries neither. The claim is read
|
|
84
|
+
* without verification and only picks which identity the CLI resolves locally;
|
|
85
|
+
* hq-pro verifies the token and authorizes every request itself.
|
|
86
|
+
*/
|
|
87
|
+
export declare function agentUidFromToken(token: string): string | null;
|
|
88
|
+
export type CallerIdentity = {
|
|
89
|
+
kind: 'person';
|
|
90
|
+
uid: string;
|
|
91
|
+
} | {
|
|
92
|
+
kind: 'agent';
|
|
93
|
+
uid: string;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the caller's own principal uid. An agent machine token resolves to
|
|
97
|
+
* its `agt_*` uid with no network call (agents have no person entity); any
|
|
98
|
+
* other token resolves to its canonical `prs_*` uid exactly as
|
|
99
|
+
* {@link resolveCallerPersonUid} does. Use this where the server accepts an
|
|
100
|
+
* agent principal; use `resolveCallerPersonUid` where only a person can act.
|
|
101
|
+
*/
|
|
102
|
+
export declare function resolveCallerUid(token: string, baseUrl?: string): Promise<CallerIdentity>;
|
|
103
|
+
/** Thrown when a person-only command runs under an agent machine identity. */
|
|
104
|
+
export declare function agentIdentityUnsupportedError(agentUid: string, command?: string): Error;
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the caller's canonical `prs_*` uid for a person-only command. An
|
|
107
|
+
* agent machine token fails fast with a clear "not available to agent
|
|
108
|
+
* identities" error instead of the person lookup's "No person entity found".
|
|
109
|
+
*/
|
|
110
|
+
export declare function resolveCallerPersonUid(token: string, baseUrl?: string, opts?: {
|
|
111
|
+
command?: string;
|
|
112
|
+
}): Promise<string>;
|
|
81
113
|
export declare function getEntityUid(token: string, opts: {
|
|
82
114
|
personal?: boolean;
|
|
83
115
|
companySlug?: string;
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -7,6 +7,7 @@ import { markExpectedUserError } from './expected-cli-error.js';
|
|
|
7
7
|
import { redactErrorText } from './redact-error-text.js';
|
|
8
8
|
import { isSecretLoadUnavailable, isUnavailableSecretLoadHttpStatus, SecretLoadUnavailableError } from './secret-load-availability.js';
|
|
9
9
|
import { readCachedScopeUid, writeCachedScopeUid } from './secrets-cache.js';
|
|
10
|
+
import { peekIdToken } from './id-token.js';
|
|
10
11
|
/**
|
|
11
12
|
* Identity / company resolution lookups must never hang forever. These small
|
|
12
13
|
* GETs run BEFORE a command does its real work (e.g. `hq secrets env` resolves
|
|
@@ -679,9 +680,57 @@ baseUrl, options = {}) {
|
|
|
679
680
|
return cachedUid;
|
|
680
681
|
}
|
|
681
682
|
}
|
|
683
|
+
const AGENT_UID_PATTERN = /^agt_[A-Za-z0-9_-]+$/;
|
|
684
|
+
/**
|
|
685
|
+
* The `agt_*` uid an agent machine token names, or null for any other token.
|
|
686
|
+
* An agent ID token carries `custom:entityType=agent` and
|
|
687
|
+
* `custom:entityUid=agt_*`; a person token carries neither. The claim is read
|
|
688
|
+
* without verification and only picks which identity the CLI resolves locally;
|
|
689
|
+
* hq-pro verifies the token and authorizes every request itself.
|
|
690
|
+
*/
|
|
691
|
+
export function agentUidFromToken(token) {
|
|
692
|
+
const claims = peekIdToken(token);
|
|
693
|
+
const entityUid = claims['custom:entityUid'];
|
|
694
|
+
if (typeof entityUid !== 'string' || !AGENT_UID_PATTERN.test(entityUid))
|
|
695
|
+
return null;
|
|
696
|
+
const entityType = claims['custom:entityType'];
|
|
697
|
+
if (entityType !== undefined && entityType !== 'agent')
|
|
698
|
+
return null;
|
|
699
|
+
return entityUid;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Resolve the caller's own principal uid. An agent machine token resolves to
|
|
703
|
+
* its `agt_*` uid with no network call (agents have no person entity); any
|
|
704
|
+
* other token resolves to its canonical `prs_*` uid exactly as
|
|
705
|
+
* {@link resolveCallerPersonUid} does. Use this where the server accepts an
|
|
706
|
+
* agent principal; use `resolveCallerPersonUid` where only a person can act.
|
|
707
|
+
*/
|
|
708
|
+
export async function resolveCallerUid(token, baseUrl) {
|
|
709
|
+
const agentUid = agentUidFromToken(token);
|
|
710
|
+
if (agentUid)
|
|
711
|
+
return { kind: 'agent', uid: agentUid };
|
|
712
|
+
return { kind: 'person', uid: await lookupCallerPersonUid(token, baseUrl) };
|
|
713
|
+
}
|
|
714
|
+
/** Thrown when a person-only command runs under an agent machine identity. */
|
|
715
|
+
export function agentIdentityUnsupportedError(agentUid, command) {
|
|
716
|
+
const what = command ? `\`${command}\`` : 'This command';
|
|
717
|
+
return markExpectedUserError(new Error(`${what} is not available to agent identities (signed in as agent ${agentUid}). ` +
|
|
718
|
+
'It acts for a person; run it from a person\'s HQ login.'));
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Resolve the caller's canonical `prs_*` uid for a person-only command. An
|
|
722
|
+
* agent machine token fails fast with a clear "not available to agent
|
|
723
|
+
* identities" error instead of the person lookup's "No person entity found".
|
|
724
|
+
*/
|
|
725
|
+
export async function resolveCallerPersonUid(token, baseUrl, opts = {}) {
|
|
726
|
+
const agentUid = agentUidFromToken(token);
|
|
727
|
+
if (agentUid)
|
|
728
|
+
throw agentIdentityUnsupportedError(agentUid, opts.command);
|
|
729
|
+
return lookupCallerPersonUid(token, baseUrl);
|
|
730
|
+
}
|
|
682
731
|
// Same selection rule as the backend's `resolveCallerPersonUid`: ascending by
|
|
683
732
|
// createdAt, tie-break by uid ascending. Returns the `prs_*` UID.
|
|
684
|
-
|
|
733
|
+
async function lookupCallerPersonUid(token, baseUrl) {
|
|
685
734
|
const res = await vaultApiFetch({
|
|
686
735
|
token,
|
|
687
736
|
path: '/entity/by-type/person',
|
|
@@ -708,16 +757,17 @@ export async function resolveCallerPersonUid(token, baseUrl) {
|
|
|
708
757
|
});
|
|
709
758
|
return persons[0].uid;
|
|
710
759
|
}
|
|
711
|
-
// Resolves the scope UID (cmp_* or
|
|
712
|
-
// `--personal` → caller's canonical person entity
|
|
760
|
+
// Resolves the scope UID (cmp_*, prs_* or agt_*) for a secrets command.
|
|
761
|
+
// Precedence: `--personal` → caller's own scope (canonical person entity, or an
|
|
762
|
+
// agent's own `agt_*` vault, which hq-pro treats as agent-self scope); else `--company <slug>` →
|
|
713
763
|
// resolved company UID; else fallback to single active company membership.
|
|
714
764
|
export async function getEntityUid(token, opts, options = {}) {
|
|
715
765
|
if (opts.personal) {
|
|
716
766
|
if (!options.allowOfflineScopeFallback)
|
|
717
|
-
return
|
|
767
|
+
return (await resolveCallerUid(token)).uid;
|
|
718
768
|
const principalUid = cognitoCachePrincipalUid(token);
|
|
719
769
|
try {
|
|
720
|
-
const uid = await
|
|
770
|
+
const { uid } = await resolveCallerUid(token);
|
|
721
771
|
if (principalUid)
|
|
722
772
|
writeCachedScopeUid("personal", principalUid, uid);
|
|
723
773
|
return uid;
|
|
@@ -47,6 +47,19 @@ export interface VersionCheckResponse {
|
|
|
47
47
|
downloadUrl?: string;
|
|
48
48
|
message?: string;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Environment for every package-manager child an update starts.
|
|
52
|
+
*
|
|
53
|
+
* - The recursion guard stops anything the install runs (lifecycle scripts,
|
|
54
|
+
* a postinstall that calls `hq`) from starting a second update.
|
|
55
|
+
* - The Corepack settings keep an update from changing the user's package
|
|
56
|
+
* manager setup. With `COREPACK_DEFAULT_TO_LATEST` unset, Corepack resolves a
|
|
57
|
+
* missing default from the registry's newest release and writes it to its
|
|
58
|
+
* global lastKnownGood file, which moved users from pnpm 10 to pnpm 12.
|
|
59
|
+
* Project selection and auto-pinning are off so the caller's working
|
|
60
|
+
* directory cannot pick, or be rewritten with, a package-manager version.
|
|
61
|
+
*/
|
|
62
|
+
export declare function packageManagerChildEnv(base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
50
63
|
export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
|
|
51
64
|
/**
|
|
52
65
|
* Whether the running package lives inside a pnpm-managed **global** install.
|
|
@@ -55,7 +55,30 @@ export function getVersionGatePhase() {
|
|
|
55
55
|
return currentVersionGatePhase;
|
|
56
56
|
}
|
|
57
57
|
function isOptedOut() {
|
|
58
|
-
|
|
58
|
+
// A process started by an update (re-exec, package-manager lifecycle script)
|
|
59
|
+
// carries the recursion guard and must never start another install.
|
|
60
|
+
return process.env.HQ_NO_UPDATE_CHECK === "1" || process.env[UPDATE_RECURSION_GUARD_ENV] === "1";
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Environment for every package-manager child an update starts.
|
|
64
|
+
*
|
|
65
|
+
* - The recursion guard stops anything the install runs (lifecycle scripts,
|
|
66
|
+
* a postinstall that calls `hq`) from starting a second update.
|
|
67
|
+
* - The Corepack settings keep an update from changing the user's package
|
|
68
|
+
* manager setup. With `COREPACK_DEFAULT_TO_LATEST` unset, Corepack resolves a
|
|
69
|
+
* missing default from the registry's newest release and writes it to its
|
|
70
|
+
* global lastKnownGood file, which moved users from pnpm 10 to pnpm 12.
|
|
71
|
+
* Project selection and auto-pinning are off so the caller's working
|
|
72
|
+
* directory cannot pick, or be rewritten with, a package-manager version.
|
|
73
|
+
*/
|
|
74
|
+
export function packageManagerChildEnv(base = process.env) {
|
|
75
|
+
return {
|
|
76
|
+
...base,
|
|
77
|
+
[UPDATE_RECURSION_GUARD_ENV]: "1",
|
|
78
|
+
COREPACK_DEFAULT_TO_LATEST: "0",
|
|
79
|
+
COREPACK_ENABLE_PROJECT_SPEC: "0",
|
|
80
|
+
COREPACK_ENABLE_AUTO_PIN: "0",
|
|
81
|
+
};
|
|
59
82
|
}
|
|
60
83
|
function findCliPackageRoot(startDir) {
|
|
61
84
|
let dir = startDir;
|
|
@@ -608,6 +631,7 @@ export function pnpmUpdateEnv(install, base = process.env) {
|
|
|
608
631
|
// project, which can silently switch the update subprocess to that version.
|
|
609
632
|
COREPACK_ENABLE_PROJECT_SPEC: "0",
|
|
610
633
|
COREPACK_DEFAULT_TO_LATEST: "0",
|
|
634
|
+
COREPACK_ENABLE_AUTO_PIN: "0",
|
|
611
635
|
};
|
|
612
636
|
if (!base.PNPM_HOME)
|
|
613
637
|
result.PNPM_HOME = home;
|
|
@@ -723,10 +747,8 @@ export function probePnpmVersion(cmd, args, env, cwd) {
|
|
|
723
747
|
export function performPnpmUpdate(install, runner = runUpdateCommand, env = process.env, probe = probePnpmVersion) {
|
|
724
748
|
const cwd = mkdtempSync(path.join(os.tmpdir(), "hq-cli-pnpm-update-"));
|
|
725
749
|
const updateEnv = {
|
|
726
|
-
...env,
|
|
727
|
-
COREPACK_ENABLE_PROJECT_SPEC: "0",
|
|
750
|
+
...packageManagerChildEnv(env),
|
|
728
751
|
COREPACK_ENABLE_STRICT: "0",
|
|
729
|
-
COREPACK_DEFAULT_TO_LATEST: "0",
|
|
730
752
|
npm_config_manage_package_manager_versions: "false",
|
|
731
753
|
};
|
|
732
754
|
try {
|
|
@@ -1125,6 +1147,14 @@ export function openInstallOutput(verbose, isTty = process.stdout.isTTY === true
|
|
|
1125
1147
|
};
|
|
1126
1148
|
}
|
|
1127
1149
|
export function runSupervisedUpdateCommand(cmd, args, output, env, cwd) {
|
|
1150
|
+
// This process was itself started by an update. Starting another install
|
|
1151
|
+
// from here is how one failed update turns into a process storm, so refuse.
|
|
1152
|
+
if (process.env[UPDATE_RECURSION_GUARD_ENV] === "1") {
|
|
1153
|
+
const detail = "refusing to start a package-manager install from inside another hq update " +
|
|
1154
|
+
`(${UPDATE_RECURSION_GUARD_ENV}=1 is set)`;
|
|
1155
|
+
console.error(chalk.yellow(`hq: ${detail}.`));
|
|
1156
|
+
return { ok: false, code: "EHQUPDATERECURSION", detail };
|
|
1157
|
+
}
|
|
1128
1158
|
try {
|
|
1129
1159
|
const plan = buildSpawnPlan(cmd, args);
|
|
1130
1160
|
const timeoutMs = updateTimeoutMs(env);
|
|
@@ -1147,7 +1177,7 @@ export function runSupervisedUpdateCommand(cmd, args, output, env, cwd) {
|
|
|
1147
1177
|
}),
|
|
1148
1178
|
], inOwnProcessGroup({
|
|
1149
1179
|
stdio: output.stdio,
|
|
1150
|
-
|
|
1180
|
+
env: packageManagerChildEnv(env ?? process.env),
|
|
1151
1181
|
...(cwd ? { cwd } : {}),
|
|
1152
1182
|
}));
|
|
1153
1183
|
// spawnSync reports a missing executable via `error`, not a throw.
|