@indigoai-us/hq-cli 5.304.0 → 5.305.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/command-catalog.generated.d.ts +1 -1
- package/dist/command-catalog.generated.js +3 -1
- package/dist/command-registration-plan.js +8 -0
- package/dist/commands/lanes.js +13 -0
- package/dist/commands/mcp-status.js +1 -0
- package/dist/commands/members.js +0 -1
- package/dist/commands/people.d.ts +3 -0
- package/dist/commands/people.js +27 -3
- package/dist/index.js +6 -1
- package/dist/lazy-commands.js +5 -2
- package/dist/lib/aws-sdk-node-warning-output.d.ts +12 -0
- package/dist/lib/aws-sdk-node-warning-output.js +22 -0
- package/dist/lib/company-errors-sdk-warning-flag.d.ts +5 -0
- package/dist/lib/company-errors-sdk-warning-flag.js +8 -0
- package/dist/lib/doctor/checks/work-context.js +2 -2
- package/dist/lib/lanes/keep-alive.js +13 -2
- package/dist/lib/mesh/live/daemon/doctor.js +2 -2
- package/dist/lib/mesh/live/flush.d.ts +2 -2
- package/dist/lib/mesh/live/flush.js +49 -29
- package/dist/lib/mesh/live/spool.d.ts +6 -0
- package/dist/lib/mesh/live/spool.js +203 -4
- package/dist/lib/work-context/bind-best.js +13 -1
- package/dist/main.js +16 -3
- package/dist/utils/self-update.d.ts +4 -0
- package/dist/utils/self-update.js +46 -9
- package/dist/utils/version-check.d.ts +7 -0
- package/dist/utils/version-check.js +30 -1
- package/dist/utils/version-gate.d.ts +28 -10
- package/dist/utils/version-gate.js +159 -29
- package/dist/utils/version-report.d.ts +2 -1
- package/dist/utils/version-report.js +8 -1
- package/package.json +1 -1
- package/dist/lib/work-context/index.d.ts +0 -26
- package/dist/lib/work-context/index.js +0 -22
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
8
9
|
import { ensureOwnerDir } from "../../work-context/atomic.js";
|
|
9
10
|
import { workMeshClaimPath, workMeshDeadLetterPath, workMeshDroppedReceiptPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "./paths.js";
|
|
10
11
|
/** Linux PIPE_BUF is typically 4096; keep a conservative ceiling for one line. */
|
|
@@ -65,12 +66,160 @@ export function appendSpoolLine(line, root) {
|
|
|
65
66
|
}
|
|
66
67
|
export function appendHeldLine(line, root) {
|
|
67
68
|
const held = workMeshHeldPath(root ?? workMeshRoot());
|
|
68
|
-
|
|
69
|
+
appendHeldLineIfAbsent(line, root);
|
|
69
70
|
return held;
|
|
70
71
|
}
|
|
72
|
+
/** Append a held envelope only when its eventId is not already held. */
|
|
73
|
+
export function appendHeldLineIfAbsent(line, root) {
|
|
74
|
+
return appendHeldLinesIfAbsent([line], root) === 1;
|
|
75
|
+
}
|
|
76
|
+
/** Append a deduplicated held batch under one file lock and one write. */
|
|
77
|
+
export function appendHeldLinesIfAbsent(lines, root) {
|
|
78
|
+
const heldPath = workMeshHeldPath(root ?? workMeshRoot());
|
|
79
|
+
ensureSpoolFile(heldPath);
|
|
80
|
+
return withJsonlLock(heldPath, () => {
|
|
81
|
+
const seen = readEventIds(heldPath);
|
|
82
|
+
const append = [];
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
const eventId = heldEventId(line);
|
|
85
|
+
if (eventId) {
|
|
86
|
+
if (seen.has(eventId))
|
|
87
|
+
continue;
|
|
88
|
+
seen.add(eventId);
|
|
89
|
+
}
|
|
90
|
+
append.push(line);
|
|
91
|
+
}
|
|
92
|
+
appendJsonlLines(heldPath, append);
|
|
93
|
+
return append.length;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
function heldEventId(line) {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(line);
|
|
99
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
100
|
+
return null;
|
|
101
|
+
const record = parsed;
|
|
102
|
+
const event = record.event && typeof record.event === "object" && !Array.isArray(record.event)
|
|
103
|
+
? record.event
|
|
104
|
+
: record;
|
|
105
|
+
return typeof event.eventId === "string" && event.eventId.trim() ? event.eventId.trim() : null;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function readEventIds(filePath) {
|
|
112
|
+
const ids = new Set();
|
|
113
|
+
let raw;
|
|
114
|
+
try {
|
|
115
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error.code === "ENOENT")
|
|
119
|
+
return ids;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
for (const line of raw.split("\n")) {
|
|
123
|
+
if (!line.trim())
|
|
124
|
+
continue;
|
|
125
|
+
const eventId = heldEventId(line);
|
|
126
|
+
if (eventId)
|
|
127
|
+
ids.add(eventId);
|
|
128
|
+
}
|
|
129
|
+
return ids;
|
|
130
|
+
}
|
|
131
|
+
function appendJsonlLines(filePath, lines) {
|
|
132
|
+
if (lines.length === 0)
|
|
133
|
+
return;
|
|
134
|
+
ensureSpoolFile(filePath);
|
|
135
|
+
fs.appendFileSync(filePath, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
|
|
136
|
+
chmodOwnerFile(filePath);
|
|
137
|
+
}
|
|
138
|
+
const JSONL_LOCK_STALE_MS = 5 * 60_000;
|
|
139
|
+
const JSONL_LOCK_TIMEOUT_MS = 5_000;
|
|
140
|
+
/** Serialize check-and-append with claims; stale locks expire after five minutes. */
|
|
141
|
+
function withJsonlLock(filePath, fn) {
|
|
142
|
+
const lockPath = `${filePath}.lock`;
|
|
143
|
+
const token = randomBytes(12).toString("hex");
|
|
144
|
+
const deadline = Date.now() + JSONL_LOCK_TIMEOUT_MS;
|
|
145
|
+
const waitCell = new Int32Array(new SharedArrayBuffer(4));
|
|
146
|
+
while (true) {
|
|
147
|
+
try {
|
|
148
|
+
const fd = fs.openSync(lockPath, "wx", 0o600);
|
|
149
|
+
try {
|
|
150
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, token, at: Date.now() }));
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
fs.closeSync(fd);
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error.code !== "EEXIST")
|
|
159
|
+
throw error;
|
|
160
|
+
let stale = false;
|
|
161
|
+
try {
|
|
162
|
+
const before = fs.statSync(lockPath);
|
|
163
|
+
stale = Date.now() - before.mtimeMs >= JSONL_LOCK_STALE_MS;
|
|
164
|
+
if (stale) {
|
|
165
|
+
const after = fs.statSync(lockPath);
|
|
166
|
+
if (before.dev === after.dev && before.ino === after.ino && after.mtimeMs === before.mtimeMs) {
|
|
167
|
+
fs.unlinkSync(lockPath);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
stale = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (statError) {
|
|
175
|
+
if (statError.code !== "ENOENT")
|
|
176
|
+
throw statError;
|
|
177
|
+
}
|
|
178
|
+
if (stale)
|
|
179
|
+
continue;
|
|
180
|
+
if (Date.now() >= deadline)
|
|
181
|
+
throw new SpoolWriteError(`Timed out waiting for ${path.basename(filePath)} lock`);
|
|
182
|
+
Atomics.wait(waitCell, 0, 0, 10);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
let result;
|
|
186
|
+
let operationError;
|
|
187
|
+
let operationFailed = false;
|
|
188
|
+
try {
|
|
189
|
+
result = fn();
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
operationError = error;
|
|
193
|
+
operationFailed = true;
|
|
194
|
+
}
|
|
195
|
+
let cleanupError;
|
|
196
|
+
try {
|
|
197
|
+
const record = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
|
198
|
+
if (record.token === token)
|
|
199
|
+
fs.unlinkSync(lockPath);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
if (error.code !== "ENOENT")
|
|
203
|
+
cleanupError = error;
|
|
204
|
+
}
|
|
205
|
+
if (operationFailed && cleanupError !== undefined) {
|
|
206
|
+
throw new AggregateError([operationError, cleanupError], "JSONL write and lock cleanup failed");
|
|
207
|
+
}
|
|
208
|
+
if (operationFailed)
|
|
209
|
+
throw operationError;
|
|
210
|
+
if (cleanupError !== undefined)
|
|
211
|
+
throw cleanupError;
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
71
214
|
export function appendDeadLetterLine(line, root) {
|
|
72
215
|
const dl = workMeshDeadLetterPath(root ?? workMeshRoot());
|
|
73
|
-
|
|
216
|
+
withJsonlLock(dl, () => {
|
|
217
|
+
ensureSpoolFile(dl);
|
|
218
|
+
const ids = readEventIds(dl);
|
|
219
|
+
const eventId = heldEventId(line);
|
|
220
|
+
if (!eventId || !ids.has(eventId))
|
|
221
|
+
appendJsonlLine(dl, line);
|
|
222
|
+
});
|
|
74
223
|
return dl;
|
|
75
224
|
}
|
|
76
225
|
export function appendDroppedReceipt(receipt, root) {
|
|
@@ -108,7 +257,8 @@ export function claimSpool(root, ts = `${Date.now()}`) {
|
|
|
108
257
|
}
|
|
109
258
|
export function claimHeld(root, ts = `${Date.now()}`) {
|
|
110
259
|
ensureOwnerDir(root);
|
|
111
|
-
|
|
260
|
+
const heldPath = workMeshHeldPath(root);
|
|
261
|
+
return withJsonlLock(heldPath, () => claimJsonlByRename(heldPath, workMeshClaimPath("held", ts, root)));
|
|
112
262
|
}
|
|
113
263
|
/** Match leftover claim files: spool.<ts>.claimed / held.<ts>.claimed (and held.<ts>.held.claimed). */
|
|
114
264
|
const ORPHAN_CLAIM_RE = /^(spool|held)\..+\.claimed$/;
|
|
@@ -195,6 +345,31 @@ export function countJsonlLines(filePath) {
|
|
|
195
345
|
}
|
|
196
346
|
return n;
|
|
197
347
|
}
|
|
348
|
+
/** Count held eventIds once; legacy rows without an eventId remain line-counted. */
|
|
349
|
+
export function countDistinctHeldEvents(filePath) {
|
|
350
|
+
let raw;
|
|
351
|
+
try {
|
|
352
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// A concurrent claim can rename held.jsonl between existence and read checks.
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
const seen = new Set();
|
|
359
|
+
let count = 0;
|
|
360
|
+
for (const line of raw.split("\n")) {
|
|
361
|
+
if (!line.trim())
|
|
362
|
+
continue;
|
|
363
|
+
const eventId = heldEventId(line);
|
|
364
|
+
if (eventId) {
|
|
365
|
+
if (seen.has(eventId))
|
|
366
|
+
continue;
|
|
367
|
+
seen.add(eventId);
|
|
368
|
+
}
|
|
369
|
+
count += 1;
|
|
370
|
+
}
|
|
371
|
+
return count;
|
|
372
|
+
}
|
|
198
373
|
function record(value) {
|
|
199
374
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
200
375
|
? value
|
|
@@ -258,8 +433,32 @@ function countUnattributedJsonlLines(filePath) {
|
|
|
258
433
|
export function countUnattributedEvents(root) {
|
|
259
434
|
const meshRoot = root ?? workMeshRoot();
|
|
260
435
|
const spool = countUnattributedJsonlLines(workMeshSpoolPath(meshRoot));
|
|
261
|
-
const held =
|
|
436
|
+
const held = countUnattributedHeldEvents(workMeshHeldPath(meshRoot));
|
|
262
437
|
const deadLetter = countUnattributedJsonlLines(workMeshDeadLetterPath(meshRoot));
|
|
263
438
|
return { spool, held, deadLetter, total: spool + held + deadLetter };
|
|
264
439
|
}
|
|
440
|
+
function countUnattributedHeldEvents(filePath) {
|
|
441
|
+
let raw;
|
|
442
|
+
try {
|
|
443
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// A concurrent held claim may rename this file between the read and open.
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
449
|
+
const seen = new Set();
|
|
450
|
+
let count = 0;
|
|
451
|
+
for (const line of raw.split("\n")) {
|
|
452
|
+
if (!line.trim() || !isUnattributedLine(line))
|
|
453
|
+
continue;
|
|
454
|
+
const eventId = heldEventId(line);
|
|
455
|
+
if (eventId) {
|
|
456
|
+
if (seen.has(eventId))
|
|
457
|
+
continue;
|
|
458
|
+
seen.add(eventId);
|
|
459
|
+
}
|
|
460
|
+
count += 1;
|
|
461
|
+
}
|
|
462
|
+
return count;
|
|
463
|
+
}
|
|
265
464
|
//# sourceMappingURL=spool.js.map
|
|
@@ -57,6 +57,17 @@ function readJson(filePath) {
|
|
|
57
57
|
return null;
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
function isInactiveProject(hqRoot, project) {
|
|
61
|
+
if (!hqRoot || !project.prdPath)
|
|
62
|
+
return false;
|
|
63
|
+
const state = readJson(path.join(hqRoot, "workspace", "orchestrator", project.projectId, "state.json"));
|
|
64
|
+
const status = typeof state?.status === "string" ? state.status.trim().toLowerCase() : "";
|
|
65
|
+
if (status !== "archived" && status !== "retired")
|
|
66
|
+
return false;
|
|
67
|
+
if (typeof state?.prd_path !== "string" || !state.prd_path.trim())
|
|
68
|
+
return false;
|
|
69
|
+
return path.resolve(hqRoot, state.prd_path) === path.resolve(project.prdPath);
|
|
70
|
+
}
|
|
60
71
|
function reposFromMetadata(metadata) {
|
|
61
72
|
if (!metadata || typeof metadata !== "object")
|
|
62
73
|
return [];
|
|
@@ -225,7 +236,8 @@ export function refineProjectBinding(input) {
|
|
|
225
236
|
const hint = input.hint?.trim();
|
|
226
237
|
if (!hint)
|
|
227
238
|
return { action: "keep" };
|
|
228
|
-
const catalog = discoverProjectsCached(input, input.nowMs ?? Date.now())
|
|
239
|
+
const catalog = discoverProjectsCached(input, input.nowMs ?? Date.now())
|
|
240
|
+
.filter((project) => !isInactiveProject(input.hqRoot?.trim(), project));
|
|
229
241
|
const exact = catalog.find((row) => row.projectId === hint);
|
|
230
242
|
if (exact?.hasBoard) {
|
|
231
243
|
return { action: "bind", projectId: hint, reason: "exact slug has a Board" };
|
package/dist/main.js
CHANGED
|
@@ -38,7 +38,7 @@ import { DEFAULT_COGNITO } from "./utils/cognito-session.js";
|
|
|
38
38
|
import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
39
39
|
import { canOfferTeamUpgrade, formatPlanGateError, offerTeamUpgrade, offerUpgradeLink, planGateErrorFromVaultError, } from "./utils/plan-gate-error.js";
|
|
40
40
|
import { upgradeToTeam } from "./utils/team-upgrade.js";
|
|
41
|
-
import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
|
|
41
|
+
import { printVersionAndNudge, refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
|
|
42
42
|
import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
43
43
|
import { autoUpdateAndReexec } from "./utils/self-update.js";
|
|
44
44
|
import { CLI_VERSION } from "./cli-version.js";
|
|
@@ -161,7 +161,8 @@ const program = new Command();
|
|
|
161
161
|
program
|
|
162
162
|
.name("hq")
|
|
163
163
|
.description("HQ management CLI — modules, packages, and cloud sync")
|
|
164
|
-
.version(CLI_VERSION)
|
|
164
|
+
.version(CLI_VERSION)
|
|
165
|
+
.option("--verbose", "Show verbose diagnostics, including SDK runtime warnings");
|
|
165
166
|
program.hook("preAction", async () => {
|
|
166
167
|
// A host that pinned HQ_AGENT_DIR has declared that it IS that agent, so
|
|
167
168
|
// every command — `hq dm`, `hq secrets`, not only `hq agent …` — must
|
|
@@ -206,6 +207,7 @@ export async function runCli() {
|
|
|
206
207
|
// already did the work, so this process only has to carry its exit status
|
|
207
208
|
// out (after the finally block's telemetry, hence not process.exit here).
|
|
208
209
|
let reexecStatus = null;
|
|
210
|
+
let versionNudgeHandled = false;
|
|
209
211
|
try {
|
|
210
212
|
runGlobalNetworkLifecycle(process.argv, () => Sentry.addBreadcrumb({
|
|
211
213
|
category: "command",
|
|
@@ -248,6 +250,15 @@ export async function runCli() {
|
|
|
248
250
|
});
|
|
249
251
|
if (versionAction === "reexec")
|
|
250
252
|
return;
|
|
253
|
+
// Commander exits immediately for its built-in --version action, before a
|
|
254
|
+
// nudge can be appended. Handle the global version-only form here so the
|
|
255
|
+
// installed semver stays the first stdout line and optional guidance follows.
|
|
256
|
+
if (process.argv.length === 3 &&
|
|
257
|
+
["--version", "-V"].includes(process.argv[2] ?? "")) {
|
|
258
|
+
versionNudgeHandled = true;
|
|
259
|
+
await printVersionAndNudge();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
251
262
|
// Register every contributor for the selected root only. Root help and
|
|
252
263
|
// unknown-command recovery use generated metadata, so they keep the full
|
|
253
264
|
// Commander surface without evaluating every command implementation.
|
|
@@ -298,7 +309,9 @@ export async function runCli() {
|
|
|
298
309
|
const limitNoticeExposureFlushes = takeCliLimitNoticeExposures().map((exposure) => emitCliLimitNoticeExposure(exposure, releaseHealthTimeoutMs));
|
|
299
310
|
const limitNoticeEngagementFlushes = takeCliLimitNoticeEngagements().map((engagement) => emitCliLimitNoticeEngagement(engagement, releaseHealthTimeoutMs));
|
|
300
311
|
await settleWithin([
|
|
301
|
-
|
|
312
|
+
...(versionNudgeHandled
|
|
313
|
+
? []
|
|
314
|
+
: [refreshVersionCache({ timeoutMs: releaseHealthTimeoutMs })]),
|
|
302
315
|
Sentry.flush(releaseHealthTimeoutMs),
|
|
303
316
|
...firstSyncOutcomeFlushes,
|
|
304
317
|
...inviteTeammateExposureFlushes,
|
|
@@ -137,6 +137,10 @@ export interface SelfUpdateDeps {
|
|
|
137
137
|
currentVersion?: string;
|
|
138
138
|
fetchLatest?: () => Promise<string | null>;
|
|
139
139
|
resolveInstall?: () => RunningInstall;
|
|
140
|
+
/** Permission preflight for an npm global prefix (tests inject a read-only result). */
|
|
141
|
+
canWriteNpmInstall?: (install: RunningInstall) => boolean;
|
|
142
|
+
/** Home directory used for the durable per-install permission notice marker. */
|
|
143
|
+
homeDir?: string;
|
|
140
144
|
/** Same-filesystem install rename, injectable for rollback tests. */
|
|
141
145
|
renamePath?: typeof fs.renameSync;
|
|
142
146
|
runner?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
|
|
@@ -65,7 +65,7 @@ import chalk from "chalk";
|
|
|
65
65
|
import { Sentry } from "../sentry.js";
|
|
66
66
|
import { isCliTelemetryDisabled } from "./cli-telemetry.js";
|
|
67
67
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
68
|
-
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, derivePnpmHome, detachUpdateSupervisor, isBunManagedPackageDir, isLocalDependencyInstall, isPnpmManagedPackageDir,
|
|
68
|
+
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, canWriteNpmInstall, captureNonWritableNpmPrefixNotice, claimNonWritableNpmPrefixNotice, checkUpdateConvergence, derivePnpmHome, detachUpdateSupervisor, isBunManagedPackageDir, isLocalDependencyInstall, isPnpmManagedPackageDir, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, } from "./version-gate.js";
|
|
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";
|
|
@@ -659,10 +659,30 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
659
659
|
console.error(chalk.dim(`hq-cli ${latest} is available, but this copy is a local dependency (${install.packageRoot}) — update the project that owns it.`));
|
|
660
660
|
return { action: "skipped", latest };
|
|
661
661
|
}
|
|
662
|
-
const staged = canStageInstall(install) && (await readStagedSelfUpdateFlag(deps, env));
|
|
663
662
|
const releaseLock = flavor.lock ? (deps.acquireLock ?? acquireUpdateLock)() : () => { };
|
|
664
663
|
if (!releaseLock)
|
|
665
664
|
return { action: "skipped", latest };
|
|
665
|
+
if (install.manager === "npm" &&
|
|
666
|
+
install.prefix &&
|
|
667
|
+
install.packageRoot &&
|
|
668
|
+
!(deps.canWriteNpmInstall ?? ((resolved) => canWriteNpmInstall(resolved.prefix, resolved.packageRoot)))(install)) {
|
|
669
|
+
const claimed = flavor.noun === "daemon update"
|
|
670
|
+
? false
|
|
671
|
+
: claimNonWritableNpmPrefixNotice(install, current, deps.homeDir);
|
|
672
|
+
if (flavor.noun === "rescue" || claimed) {
|
|
673
|
+
console.error(chalk.yellow(` ${nonWritablePrefixNote(install.prefix)}`));
|
|
674
|
+
}
|
|
675
|
+
await captureNonWritableNpmPrefixNotice({
|
|
676
|
+
fromVersion: current,
|
|
677
|
+
targetVersion: latestValid,
|
|
678
|
+
install,
|
|
679
|
+
env,
|
|
680
|
+
homeDir: deps.homeDir,
|
|
681
|
+
});
|
|
682
|
+
releaseLock();
|
|
683
|
+
return { action: "update-failed", latest };
|
|
684
|
+
}
|
|
685
|
+
const staged = canStageInstall(install) && (await readStagedSelfUpdateFlag(deps, env));
|
|
666
686
|
const renamePath = deps.renamePath ?? fs.renameSync;
|
|
667
687
|
// The in-place update moves the package aside, and the installer's
|
|
668
688
|
// supervisor ships inside it, so run the installer through a copy.
|
|
@@ -732,12 +752,27 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
732
752
|
console.error(chalk.yellow(`⚠ hq-cli ${latest} is available but the update failed` +
|
|
733
753
|
`${result.detail ? `: ${result.detail}` : ""}`));
|
|
734
754
|
console.error(chalk.dim(` Try manually: ${plan.cmd} ${plan.args.join(" ")}`));
|
|
735
|
-
// A
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
755
|
+
// A permission wall discovered after npm fails is handled as an expected
|
|
756
|
+
// install-state warning. The preflight normally catches it before spawn;
|
|
757
|
+
// this check covers ACL changes and other races after the preflight.
|
|
758
|
+
const npmPrefixNotWritable = install.manager === "npm" &&
|
|
759
|
+
install.prefix &&
|
|
760
|
+
install.packageRoot &&
|
|
761
|
+
!(deps.canWriteNpmInstall ?? ((resolved) => canWriteNpmInstall(resolved.prefix, resolved.packageRoot)))(install);
|
|
762
|
+
if (npmPrefixNotWritable) {
|
|
763
|
+
const prefixNoticeClaimed = flavor.noun === "daemon update"
|
|
764
|
+
? false
|
|
765
|
+
: claimNonWritableNpmPrefixNotice(install, current, deps.homeDir);
|
|
766
|
+
if (flavor.noun === "rescue" || prefixNoticeClaimed) {
|
|
767
|
+
console.error(chalk.yellow(` ${nonWritablePrefixNote(install.prefix)}`));
|
|
768
|
+
}
|
|
769
|
+
await captureNonWritableNpmPrefixNotice({
|
|
770
|
+
fromVersion: current,
|
|
771
|
+
targetVersion: latestValid,
|
|
772
|
+
install,
|
|
773
|
+
env,
|
|
774
|
+
homeDir: deps.homeDir,
|
|
775
|
+
});
|
|
741
776
|
}
|
|
742
777
|
// A manager-level failure often means the install layout itself is broken
|
|
743
778
|
// (e.g. a hand-rolled pnpm store nested inside the app's bin dir). The
|
|
@@ -748,7 +783,9 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
748
783
|
console.error(chalk.dim(` Or repair the install: hq rescue`));
|
|
749
784
|
}
|
|
750
785
|
console.error(chalk.dim(` Continuing the ${flavor.noun} on ${current}.`));
|
|
751
|
-
|
|
786
|
+
if (!npmPrefixNotWritable) {
|
|
787
|
+
await captureSelfUpdateOutcome("update-failed", current, latestValid, install.manager, result, env, flavor.noun === "rescue");
|
|
788
|
+
}
|
|
752
789
|
return { action: "update-failed", latest };
|
|
753
790
|
}
|
|
754
791
|
if (installSnapshot)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare const CLI_VERSION_NUDGE_FLAG = "cli.version-update-nudge";
|
|
1
2
|
declare function isKnownNoninteractiveStatusProbe(argv?: readonly string[]): boolean;
|
|
2
3
|
/**
|
|
3
4
|
* The cached npm `latest` when it is newer than the running version, else null.
|
|
@@ -21,6 +22,12 @@ export interface RefreshVersionCacheOptions {
|
|
|
21
22
|
timeoutMs?: number;
|
|
22
23
|
}
|
|
23
24
|
export declare function refreshVersionCache(options?: RefreshVersionCacheOptions): Promise<void>;
|
|
25
|
+
/** Write the optional update guidance from the existing npm version cache.
|
|
26
|
+
* This helper never performs a network request; a missing or stale cache means
|
|
27
|
+
* there is no nudge to show.
|
|
28
|
+
*/
|
|
29
|
+
export declare function printCachedVersionNudge(stderr?: Pick<NodeJS.WriteStream, "write">): Promise<void>;
|
|
30
|
+
export declare function printVersionAndNudge(stdout?: Pick<NodeJS.WriteStream, "write">, stderr?: Pick<NodeJS.WriteStream, "write">): Promise<void>;
|
|
24
31
|
export declare const __test__: {
|
|
25
32
|
CACHE_TTL_MS: number;
|
|
26
33
|
isKnownNoninteractiveStatusProbe: typeof isKnownNoninteractiveStatusProbe;
|
|
@@ -9,6 +9,7 @@ const CACHE_TTL_MS = 60 * 60 * 1000; // 1h — catch fresh releases within the h
|
|
|
9
9
|
const CACHE_TTL_JITTER_MS = 5 * 60 * 1000; // up to 5m early, to spread a fleet's refreshes off a single instant
|
|
10
10
|
const FETCH_TIMEOUT_MS = 3_000;
|
|
11
11
|
const REFRESH_LOCK_STALE_MS = 10 * 60 * 1000;
|
|
12
|
+
export const CLI_VERSION_NUDGE_FLAG = "cli.version-update-nudge";
|
|
12
13
|
/**
|
|
13
14
|
* How long to stop auto-retrying an `ineffectiveLatest` target. Long enough to
|
|
14
15
|
* break the per-invocation loop on a busy agent box, short enough that a box
|
|
@@ -67,7 +68,7 @@ function isKnownNoninteractiveStatusProbe(argv = process.argv) {
|
|
|
67
68
|
const json = args.includes("--json") || !process.stdout.isTTY;
|
|
68
69
|
if (!json)
|
|
69
70
|
return false;
|
|
70
|
-
if (positional[0] === "mcp" && positional[1] === "status")
|
|
71
|
+
if (positional[0] === "mcp" && (positional[1] === "status" || positional[1] === "list"))
|
|
71
72
|
return true;
|
|
72
73
|
if (positional[0] === "packs" && (positional[1] === "list" || positional[1] === "ls"))
|
|
73
74
|
return true;
|
|
@@ -199,6 +200,34 @@ export async function refreshVersionCache(options = {}) {
|
|
|
199
200
|
releaseLock();
|
|
200
201
|
}
|
|
201
202
|
}
|
|
203
|
+
/** Write the optional update guidance from the existing npm version cache.
|
|
204
|
+
* This helper never performs a network request; a missing or stale cache means
|
|
205
|
+
* there is no nudge to show.
|
|
206
|
+
*/
|
|
207
|
+
export async function printCachedVersionNudge(stderr = process.stderr) {
|
|
208
|
+
if (isOptedOut())
|
|
209
|
+
return;
|
|
210
|
+
let resolveProcessFlagGate;
|
|
211
|
+
try {
|
|
212
|
+
({ resolveProcessFlagGate } = await import("../lib/flag-registry.js"));
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
const errorClass = error instanceof Error ? error.name : "unknown";
|
|
216
|
+
stderr.write(`hq: Update nudge unavailable (${errorClass}).\n`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const enabled = resolveProcessFlagGate(CLI_VERSION_NUDGE_FLAG, { fallback: false }, () => false);
|
|
220
|
+
if (!enabled)
|
|
221
|
+
return;
|
|
222
|
+
const latest = staleAgainstCachedLatest();
|
|
223
|
+
if (latest) {
|
|
224
|
+
stderr.write(`Update available: ${latest}. Update with npm install -g @indigoai-us/hq-cli@latest, pnpm add -g @indigoai-us/hq-cli@latest, or bun add -g @indigoai-us/hq-cli@latest.\n`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
export async function printVersionAndNudge(stdout = process.stdout, stderr = process.stderr) {
|
|
228
|
+
stdout.write(`${CLI_VERSION}\n`);
|
|
229
|
+
await printCachedVersionNudge(stderr);
|
|
230
|
+
}
|
|
202
231
|
export const __test__ = {
|
|
203
232
|
CACHE_TTL_MS,
|
|
204
233
|
isKnownNoninteractiveStatusProbe,
|
|
@@ -151,9 +151,9 @@ export declare function resolveRunningManager(): InstallManager;
|
|
|
151
151
|
/** Convenience view of {@link resolveRunningInstall} for callers needing one field. */
|
|
152
152
|
export declare function resolveRunningPrefix(): string | null;
|
|
153
153
|
/**
|
|
154
|
-
* Whether the current process can write the npm global
|
|
155
|
-
* an `npm install -g --prefix <prefix>` could actually replace the
|
|
156
|
-
* CLI, or would fail with EACCES.
|
|
154
|
+
* Whether the current process can write the npm global install targets — i.e.
|
|
155
|
+
* whether an `npm install -g --prefix <prefix>` could actually replace the
|
|
156
|
+
* installed CLI, or would fail with EACCES.
|
|
157
157
|
*
|
|
158
158
|
* The path npm rewrites is the prefix's `bin` dir (`<prefix>/bin/hq` on unix
|
|
159
159
|
* globals — the `rename /usr/bin/hq` EACCES the agent boxes hit), so that is
|
|
@@ -167,13 +167,27 @@ export declare function resolveRunningPrefix(): string | null;
|
|
|
167
167
|
*/
|
|
168
168
|
export declare function isPrefixWritable(prefix: string, access?: (target: string, mode: number) => void): boolean;
|
|
169
169
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
170
|
+
* Check the directories an npm global update must mutate before spawning npm.
|
|
171
|
+
* Npm replaces the package under node_modules and writes executable links
|
|
172
|
+
* under prefix/bin. The package's parent must be writable because npm renames
|
|
173
|
+
* the old package there.
|
|
174
|
+
*
|
|
175
|
+
* When one of those directories has not been created yet, check its nearest
|
|
176
|
+
* existing ancestor: npm can create a child only if that ancestor is writable.
|
|
175
177
|
*/
|
|
176
|
-
export declare function
|
|
178
|
+
export declare function canWriteNpmInstall(prefix: string, packageRoot: string, access?: (target: string, mode: number) => void): boolean;
|
|
179
|
+
/** Claim the one local warning/report for this installed version and prefix. */
|
|
180
|
+
export declare function claimNonWritableNpmPrefixNotice(install: Pick<RunningInstall, "manager" | "prefix" | "packageRoot">, fromVersion: string, homeDir?: string, kind?: "user" | "report"): boolean;
|
|
181
|
+
/** Emit one path-free warning event for a handled non-writable npm prefix. */
|
|
182
|
+
export declare function captureNonWritableNpmPrefixNotice(input: {
|
|
183
|
+
fromVersion: string;
|
|
184
|
+
targetVersion: string;
|
|
185
|
+
install: Pick<RunningInstall, "manager" | "prefix" | "packageRoot">;
|
|
186
|
+
env?: NodeJS.ProcessEnv;
|
|
187
|
+
homeDir?: string;
|
|
188
|
+
}): Promise<void>;
|
|
189
|
+
/** One-line repair guidance for a global npm install the current user cannot write. */
|
|
190
|
+
export declare function nonWritablePrefixNote(prefix: string, platform?: NodeJS.Platform): string;
|
|
177
191
|
/**
|
|
178
192
|
* Derive `PNPM_HOME` from a pnpm-managed install's own path. pnpm resolves its
|
|
179
193
|
* global bin directory from `PNPM_HOME` (or an explicit `global-bin-dir`), and
|
|
@@ -411,6 +425,8 @@ export declare function checkUpdateConvergence(targetVersion: string, deps?: {
|
|
|
411
425
|
interface EnforceUpdateDeps {
|
|
412
426
|
performUpdateString?: (command: string) => UpdateResult;
|
|
413
427
|
resolveInstall?: () => RunningInstall;
|
|
428
|
+
canWriteNpmInstall?: (install: RunningInstall) => boolean;
|
|
429
|
+
homeDir?: string;
|
|
414
430
|
runner?: UpdateRunner;
|
|
415
431
|
platform?: NodeJS.Platform;
|
|
416
432
|
cleanStale?: (prefix: string) => string[];
|
|
@@ -442,7 +458,7 @@ interface EnforceUpdateDeps {
|
|
|
442
458
|
* A timed-out updater is the exception: it has been forcibly reaped, so the
|
|
443
459
|
* gate returns and lets the user's command run on the current version.
|
|
444
460
|
*/
|
|
445
|
-
declare function enforceUpdateRequired(decision: VersionCheckResponse, deps?: EnforceUpdateDeps): void
|
|
461
|
+
declare function enforceUpdateRequired(decision: VersionCheckResponse, deps?: EnforceUpdateDeps): void | Promise<void>;
|
|
446
462
|
/**
|
|
447
463
|
* Public entry point. Call before commander parses argv. Blocks the CLI on
|
|
448
464
|
* network IO for up to FETCH_TIMEOUT_MS — acceptable because the alternative
|
|
@@ -484,6 +500,8 @@ export declare const __test__: {
|
|
|
484
500
|
buildPnpmInstallArgv: typeof buildPnpmInstallArgv;
|
|
485
501
|
buildPrefixedInstallArgv: typeof buildPrefixedInstallArgv;
|
|
486
502
|
buildSpawnPlan: typeof buildSpawnPlan;
|
|
503
|
+
canWriteNpmInstall: typeof canWriteNpmInstall;
|
|
504
|
+
claimNonWritableNpmPrefixNotice: typeof claimNonWritableNpmPrefixNotice;
|
|
487
505
|
cleanStalePartialInstall: typeof cleanStalePartialInstall;
|
|
488
506
|
derivePnpmHome: typeof derivePnpmHome;
|
|
489
507
|
enforceUpdateRequired: typeof enforceUpdateRequired;
|