@indigoai-us/hq-cli 5.298.0 → 5.300.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 +11 -0
- package/dist/command-catalog.generated.d.ts +27 -4
- package/dist/command-catalog.generated.js +34 -4
- package/dist/commands/cloud.js +12 -7
- package/dist/commands/daemon.js +1 -0
- package/dist/commands/files.d.ts +1 -0
- package/dist/commands/files.js +59 -0
- package/dist/commands/lanes-accounts.d.ts +3 -0
- package/dist/commands/lanes-accounts.js +25 -1
- package/dist/commands/lanes-message.js +68 -35
- package/dist/commands/lanes-resume.js +247 -210
- package/dist/commands/lanes-shared.d.ts +3 -1
- package/dist/commands/lanes.d.ts +2 -0
- package/dist/commands/lanes.js +16 -4
- package/dist/lib/daemon/legacy.d.ts +5 -2
- package/dist/lib/daemon/legacy.js +6 -4
- package/dist/lib/daemon/migrate.d.ts +10 -1
- package/dist/lib/daemon/migrate.js +22 -7
- package/dist/lib/daemon/run.js +19 -7
- package/dist/lib/daemon/supervisor.js +9 -1
- package/dist/lib/lanes/quota.d.ts +19 -0
- package/dist/lib/lanes/quota.js +106 -29
- package/dist/lib/lanes/reconcile.js +34 -5
- package/dist/lib/lanes/store.d.ts +2 -0
- package/dist/lib/lanes/store.js +4 -0
- package/dist/lib/lanes/story-switch.d.ts +21 -0
- package/dist/lib/lanes/story-switch.js +75 -0
- package/dist/lib/lanes/types.d.ts +25 -0
- package/dist/lib/lanes/types.js +1 -1
- package/dist/lib/lanes/watcher.js +25 -5
- package/dist/lib/mesh/live/daemon/install.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/install.js +13 -2
- package/dist/utils/self-update.js +249 -2
- package/dist/utils/staged-install.js +71 -21
- package/package.json +1 -1
|
@@ -37,10 +37,11 @@ import { LanesError } from "./errors.js";
|
|
|
37
37
|
import { questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
|
|
38
38
|
import { isTerminalLaneState, ENVELOPE_INVALID_REASON, parseLaneEnvelope, STORY_STATUSES, LANE_PROVIDERS, } from "./types.js";
|
|
39
39
|
import { laneStories, terminalEnvelopeStoryStatus } from "./stories.js";
|
|
40
|
+
import { applyStorySwitchEnvelope } from "./story-switch.js";
|
|
40
41
|
import { appendCappedHistory, listQuestionIds, readLane, readQuestion, retryUpdateLane, withLaneHireLocks } from "./store.js";
|
|
41
42
|
import { enforceRegistryApprovalGate } from "./approval-gate.js";
|
|
42
43
|
import { readWorkerRunResult } from "./delivery.js";
|
|
43
|
-
import { applyDefaultQuotaHold, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
|
|
44
|
+
import { applyDefaultQuotaHold, clearAccountQuotaAfterSuccessfulTurnBestEffort, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
|
|
44
45
|
import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, isProviderLoginFailure, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
|
|
45
46
|
import { appendDropboxMessageAtPath, dropboxInboxHasMessages, readToolActivity, toolActivityPath } from "./dropbox.js";
|
|
46
47
|
import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
|
|
@@ -414,6 +415,7 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
|
|
|
414
415
|
}), current.registry_worker_approval_history ?? []);
|
|
415
416
|
}
|
|
416
417
|
if (envelope) {
|
|
418
|
+
const storySwitch = applyStorySwitchEnvelope(current, envelope, at, envelopeRef);
|
|
417
419
|
if (envelope.decision !== "blocked")
|
|
418
420
|
next.keep_alive_blocked_resume_streak = 0;
|
|
419
421
|
next.last_envelope = {
|
|
@@ -423,11 +425,20 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
|
|
|
423
425
|
artifacts: envelope.artifacts,
|
|
424
426
|
...(envelope.blocked_reason !== undefined ? { blocked_reason: envelope.blocked_reason } : {}),
|
|
425
427
|
};
|
|
426
|
-
|
|
428
|
+
if (storySwitch.clearPending) {
|
|
429
|
+
next.story_switch_pending = undefined;
|
|
430
|
+
}
|
|
431
|
+
if (storySwitch.storySwitchUnacknowledged !== undefined) {
|
|
432
|
+
next.story_switch_unacknowledged = storySwitch.storySwitchUnacknowledged;
|
|
433
|
+
next.attribution_history = storySwitch.attributionHistory ?? current.attribution_history;
|
|
434
|
+
}
|
|
435
|
+
const storyStatus = storySwitch.suppressCompletion
|
|
436
|
+
? "in_progress"
|
|
437
|
+
: terminalEnvelopeStoryStatus(current, envelope.decision, envelope.story_status);
|
|
427
438
|
if (storyStatus !== undefined) {
|
|
428
|
-
next.story_status = envelope.story_status;
|
|
429
|
-
next.story_complete = envelope.story_complete;
|
|
430
|
-
next.story_complete_reason = envelope.story_complete_reason;
|
|
439
|
+
next.story_status = storySwitch.suppressCompletion ? "in_progress" : envelope.story_status;
|
|
440
|
+
next.story_complete = storySwitch.suppressCompletion ? false : envelope.story_complete;
|
|
441
|
+
next.story_complete_reason = storySwitch.suppressCompletion ? undefined : envelope.story_complete_reason;
|
|
431
442
|
const currentProject = current.project_id;
|
|
432
443
|
const currentStory = current.story_id;
|
|
433
444
|
const updatedStories = laneStories(current).map((story) => story.project_id === currentProject && story.story_id === currentStory
|
|
@@ -445,6 +456,15 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
|
|
|
445
456
|
}
|
|
446
457
|
return next;
|
|
447
458
|
});
|
|
459
|
+
if (updated.state === "done" &&
|
|
460
|
+
effectiveClassification.state === "done" &&
|
|
461
|
+
effectiveEnvelope?.decision === "done" &&
|
|
462
|
+
opts.quota == null &&
|
|
463
|
+
opts.providerRejection == null &&
|
|
464
|
+
updated.account &&
|
|
465
|
+
updated.model) {
|
|
466
|
+
clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot, updated.provider, updated.account, updated.model, new Date(at));
|
|
467
|
+
}
|
|
448
468
|
notifySlotFreed({
|
|
449
469
|
hqRoot,
|
|
450
470
|
provider: updated.provider,
|
|
@@ -307,6 +307,9 @@ export declare function uninstallDaemonService(opts: {
|
|
|
307
307
|
unlinkSync?: typeof fs.unlinkSync;
|
|
308
308
|
existsSync?: typeof fs.existsSync;
|
|
309
309
|
deactivate?: (platform: DaemonInstallPlatform, dest: string) => void;
|
|
310
|
+
/** Service-manager runner for uninstall deactivation (tests inject; never the real binary). */
|
|
311
|
+
run?: RunCommand;
|
|
312
|
+
uid?: number;
|
|
310
313
|
/** Windows: schtasks.exe runner (tests inject; never the real binary). */
|
|
311
314
|
schtasks?: SchtasksRunner;
|
|
312
315
|
/** Windows: terminate the recorded daemon pid after `schtasks /End` (tests inject). */
|
|
@@ -22,7 +22,7 @@ import { isInstallableHqRoot, readDaemonConfig, registerTrustedHqRoot, verifyHqR
|
|
|
22
22
|
import { defaultPidLockDeps, pidLockStatus, readPidLock } from "./pid-lock.js";
|
|
23
23
|
import { retireLegacyListen } from "./legacy-listen.js";
|
|
24
24
|
import { readDaemonState } from "./state.js";
|
|
25
|
-
import { activateLaunchd, launchdIsLoaded, launchdPlistProgramArguments, launchdProgramArgumentsDiffer, parseLaunchctlPrintArguments, } from "../../../service-manager/launchd.js";
|
|
25
|
+
import { activateLaunchd, defaultRunCommand, launchdIsLoaded, launchdServiceTarget, launchdPlistProgramArguments, launchdProgramArgumentsDiffer, parseLaunchctlPrintArguments, } from "../../../service-manager/launchd.js";
|
|
26
26
|
import { activateSystemd, systemdIsActive } from "../../../service-manager/systemd.js";
|
|
27
27
|
import { nodeBinaryProblem, preferStableNodeBinary } from "../../../node-binary.js";
|
|
28
28
|
/**
|
|
@@ -955,7 +955,18 @@ export function uninstallDaemonService(opts) {
|
|
|
955
955
|
}
|
|
956
956
|
if (platform === "darwin") {
|
|
957
957
|
if (existsSync(paths.plistPath)) {
|
|
958
|
-
opts.deactivate
|
|
958
|
+
if (opts.deactivate) {
|
|
959
|
+
opts.deactivate(platform, paths.plistPath);
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
const run = opts.run ?? defaultRunCommand;
|
|
963
|
+
const target = launchdServiceTarget(paths.plistPath, opts.uid);
|
|
964
|
+
const bootout = run("launchctl", ["bootout", target]);
|
|
965
|
+
if (bootout.status !== 0 && run("launchctl", ["print", target]).status === 0) {
|
|
966
|
+
const detail = (bootout.stderr || bootout.stdout).trim();
|
|
967
|
+
throw new Error(`launchctl bootout failed for ${target}${detail ? `: ${detail}` : ` (exit ${bootout.status})`}`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
959
970
|
unlinkSync(paths.plistPath);
|
|
960
971
|
}
|
|
961
972
|
return {
|
|
@@ -131,6 +131,236 @@ function isPathInside(candidate, parent) {
|
|
|
131
131
|
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
|
|
132
132
|
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
133
133
|
}
|
|
134
|
+
function readNpmBinMap(packageRoot) {
|
|
135
|
+
try {
|
|
136
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|
|
137
|
+
if (manifest.bin === undefined)
|
|
138
|
+
return {};
|
|
139
|
+
const entries = typeof manifest.bin === "string"
|
|
140
|
+
? [["hq", manifest.bin]]
|
|
141
|
+
: manifest.bin && typeof manifest.bin === "object" && !Array.isArray(manifest.bin)
|
|
142
|
+
? Object.entries(manifest.bin)
|
|
143
|
+
: null;
|
|
144
|
+
if (!entries)
|
|
145
|
+
return null;
|
|
146
|
+
const bins = {};
|
|
147
|
+
for (const [name, target] of entries) {
|
|
148
|
+
if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\") ||
|
|
149
|
+
typeof target !== "string" || target.length === 0 || path.isAbsolute(target) ||
|
|
150
|
+
!isPathInside(path.resolve(packageRoot, target), packageRoot)) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
bins[name] = target;
|
|
154
|
+
}
|
|
155
|
+
return bins;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function symlinkTargetIsInsidePackage(linkPath, target, packageRoot) {
|
|
162
|
+
return isPathInside(path.resolve(path.dirname(linkPath), target), packageRoot);
|
|
163
|
+
}
|
|
164
|
+
function snapshotNpmBinLinks(prefix, packageRoot) {
|
|
165
|
+
if (process.platform === "win32")
|
|
166
|
+
return [];
|
|
167
|
+
const binMap = readNpmBinMap(packageRoot);
|
|
168
|
+
if (!binMap)
|
|
169
|
+
return [];
|
|
170
|
+
const binDirectory = path.join(prefix, "bin");
|
|
171
|
+
const links = [];
|
|
172
|
+
for (const name of Object.keys(binMap)) {
|
|
173
|
+
const linkPath = path.join(binDirectory, name);
|
|
174
|
+
try {
|
|
175
|
+
if (!fs.lstatSync(linkPath).isSymbolicLink())
|
|
176
|
+
continue;
|
|
177
|
+
const target = fs.readlinkSync(linkPath);
|
|
178
|
+
const resolved = fs.realpathSync(linkPath);
|
|
179
|
+
if (!isPathInside(resolved, packageRoot))
|
|
180
|
+
continue;
|
|
181
|
+
links.push({ path: linkPath, target });
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// A missing or dangling entry was not a working npm bin link to snapshot.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return links;
|
|
188
|
+
}
|
|
189
|
+
function restoreNpmBinLinks(snapshot) {
|
|
190
|
+
if (!snapshot.npmBinDirectory || !snapshot.npmBinPackageRoot || !snapshot.npmBinLinks?.length)
|
|
191
|
+
return true;
|
|
192
|
+
if (process.platform === "win32")
|
|
193
|
+
return true;
|
|
194
|
+
let restored = true;
|
|
195
|
+
for (const bin of snapshot.npmBinLinks) {
|
|
196
|
+
if (path.dirname(bin.path) !== snapshot.npmBinDirectory) {
|
|
197
|
+
reportBinLinkConflict(bin.path, "the saved path is outside the npm bin directory");
|
|
198
|
+
restored = false;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
let existing;
|
|
202
|
+
let existingTarget;
|
|
203
|
+
try {
|
|
204
|
+
existing = fs.lstatSync(bin.path);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error.code !== "ENOENT") {
|
|
208
|
+
reportBinLinkConflict(bin.path, errorMessage(error));
|
|
209
|
+
restored = false;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (existing) {
|
|
214
|
+
if (!existing.isSymbolicLink()) {
|
|
215
|
+
reportBinLinkConflict(bin.path, "a regular file owns this command name");
|
|
216
|
+
restored = false;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const currentTarget = fs.readlinkSync(bin.path);
|
|
220
|
+
existingTarget = currentTarget;
|
|
221
|
+
if (!symlinkTargetIsInsidePackage(bin.path, currentTarget, snapshot.npmBinPackageRoot)) {
|
|
222
|
+
reportBinLinkConflict(bin.path, "the existing symlink points outside hq-cli");
|
|
223
|
+
restored = false;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
if (currentTarget === bin.target &&
|
|
228
|
+
isPathInside(fs.realpathSync(bin.path), snapshot.npmBinPackageRoot))
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// A dangling link into the package is safe to replace with the snapshot.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!symlinkTargetIsInsidePackage(bin.path, bin.target, snapshot.npmBinPackageRoot)) {
|
|
236
|
+
reportBinLinkConflict(bin.path, "the saved npm link target is outside the restored hq-cli package");
|
|
237
|
+
restored = false;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const temporary = `${bin.path}.hq-cli-restore-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
241
|
+
try {
|
|
242
|
+
fs.mkdirSync(snapshot.npmBinDirectory, { recursive: true });
|
|
243
|
+
if (!existing) {
|
|
244
|
+
// Creating the link directly is deliberately no-clobber: if another
|
|
245
|
+
// tool claimed the command name after lstat, EEXIST preserves it.
|
|
246
|
+
fs.symlinkSync(bin.target, bin.path);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
fs.symlinkSync(bin.target, temporary);
|
|
250
|
+
const current = fs.lstatSync(bin.path);
|
|
251
|
+
if (!current.isSymbolicLink() || fs.readlinkSync(bin.path) !== existingTarget) {
|
|
252
|
+
throw new Error("the bin entry changed while it was being restored");
|
|
253
|
+
}
|
|
254
|
+
fs.renameSync(temporary, bin.path);
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
if (!isPathInside(fs.realpathSync(bin.path), snapshot.npmBinPackageRoot)) {
|
|
258
|
+
throw new Error("restored link does not resolve into the restored hq-cli package");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
fs.rmSync(bin.path, { force: true });
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
fs.rmSync(temporary, { force: true });
|
|
268
|
+
reportBinLinkConflict(bin.path, errorMessage(error));
|
|
269
|
+
restored = false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return restored;
|
|
273
|
+
}
|
|
274
|
+
function reportBinLinkConflict(linkPath, reason) {
|
|
275
|
+
console.error(chalk.yellow(`⚠ Could not restore hq-cli bin link at ${linkPath}; left it untouched (${reason}).`));
|
|
276
|
+
}
|
|
277
|
+
function reportForeignNpmBin(linkPath, reason) {
|
|
278
|
+
console.error(chalk.yellow(`⚠ Leaving hq-cli bin entry at ${linkPath} untouched; another tool owns it (${reason}).`));
|
|
279
|
+
}
|
|
280
|
+
function errorMessage(error) {
|
|
281
|
+
return error instanceof Error ? error.message : String(error);
|
|
282
|
+
}
|
|
283
|
+
function verifyOrRepairNpmBins(prefix, packageRoot) {
|
|
284
|
+
if (process.platform === "win32")
|
|
285
|
+
return { ok: true };
|
|
286
|
+
let livePackageRoot;
|
|
287
|
+
try {
|
|
288
|
+
livePackageRoot = fs.realpathSync(packageRoot);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
return { ok: false, detail: `the installed hq-cli package is missing: ${errorMessage(error)}` };
|
|
292
|
+
}
|
|
293
|
+
const bins = readNpmBinMap(livePackageRoot);
|
|
294
|
+
if (!bins || typeof bins.hq !== "string" || bins.hq.length === 0) {
|
|
295
|
+
return { ok: false, detail: "the installed hq-cli package does not declare a valid hq bin" };
|
|
296
|
+
}
|
|
297
|
+
const binDirectory = path.join(prefix, "bin");
|
|
298
|
+
for (const [name, target] of Object.entries(bins)) {
|
|
299
|
+
const linkPath = path.join(binDirectory, name);
|
|
300
|
+
const expected = path.resolve(livePackageRoot, target);
|
|
301
|
+
let existing;
|
|
302
|
+
let existingTarget;
|
|
303
|
+
try {
|
|
304
|
+
existing = fs.lstatSync(linkPath);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (error.code !== "ENOENT") {
|
|
308
|
+
return { ok: false, detail: `could not inspect ${linkPath}: ${errorMessage(error)}` };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (existing) {
|
|
312
|
+
if (!existing.isSymbolicLink()) {
|
|
313
|
+
if (name === "hq") {
|
|
314
|
+
return { ok: false, detail: `cannot repair ${linkPath}: a regular file owns this command name` };
|
|
315
|
+
}
|
|
316
|
+
reportForeignNpmBin(linkPath, "a regular file owns this command name");
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const currentTarget = fs.readlinkSync(linkPath);
|
|
320
|
+
existingTarget = currentTarget;
|
|
321
|
+
if (!symlinkTargetIsInsidePackage(linkPath, currentTarget, livePackageRoot)) {
|
|
322
|
+
if (name === "hq") {
|
|
323
|
+
return { ok: false, detail: `cannot repair ${linkPath}: the existing symlink points outside hq-cli` };
|
|
324
|
+
}
|
|
325
|
+
reportForeignNpmBin(linkPath, "the existing symlink points outside hq-cli");
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
if (fs.realpathSync(linkPath) === expected && fs.statSync(linkPath).isFile())
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// Missing and dangling hq-cli links are repaired below.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const temporary = `${linkPath}.hq-cli-repair-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
337
|
+
try {
|
|
338
|
+
fs.mkdirSync(binDirectory, { recursive: true });
|
|
339
|
+
const targetPath = path.relative(binDirectory, expected);
|
|
340
|
+
if (!existing) {
|
|
341
|
+
// A direct symlink create does not replace a file that appeared after
|
|
342
|
+
// lstat; EEXIST is surfaced as an update failure instead.
|
|
343
|
+
fs.symlinkSync(targetPath, linkPath);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
fs.symlinkSync(targetPath, temporary);
|
|
347
|
+
const current = fs.lstatSync(linkPath);
|
|
348
|
+
if (!current.isSymbolicLink() || fs.readlinkSync(linkPath) !== existingTarget) {
|
|
349
|
+
throw new Error("the bin entry changed while it was being repaired");
|
|
350
|
+
}
|
|
351
|
+
fs.renameSync(temporary, linkPath);
|
|
352
|
+
}
|
|
353
|
+
if (fs.realpathSync(linkPath) !== expected || !fs.statSync(linkPath).isFile()) {
|
|
354
|
+
throw new Error("repaired link does not resolve to its declared package bin");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
fs.rmSync(temporary, { force: true });
|
|
359
|
+
return { ok: false, detail: `could not repair ${linkPath}: ${errorMessage(error)}` };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { ok: true };
|
|
363
|
+
}
|
|
134
364
|
function isInsideNodeModules(candidate) {
|
|
135
365
|
const segments = path.resolve(candidate).replace(/\\/g, "/").split("/").filter(Boolean);
|
|
136
366
|
const index = process.platform === "win32"
|
|
@@ -159,8 +389,14 @@ function snapshotPathsAreSafe(snapshot) {
|
|
|
159
389
|
snapshot.movedPath,
|
|
160
390
|
snapshot.resolvedTarget,
|
|
161
391
|
snapshot.publicLinkPath,
|
|
392
|
+
snapshot.npmBinPackageRoot,
|
|
162
393
|
].filter((value) => value !== undefined);
|
|
163
|
-
|
|
394
|
+
if (!paths.every(isInsideNodeModules))
|
|
395
|
+
return false;
|
|
396
|
+
if (snapshot.npmBinLinks?.some((bin) => !snapshot.npmBinDirectory || path.dirname(bin.path) !== snapshot.npmBinDirectory ||
|
|
397
|
+
!isPathInside(bin.path, snapshot.npmBinDirectory)))
|
|
398
|
+
return false;
|
|
399
|
+
return true;
|
|
164
400
|
}
|
|
165
401
|
function snapshotInstall(install, renamePath = fs.renameSync) {
|
|
166
402
|
const packageRoot = install.packageRoot;
|
|
@@ -200,6 +436,9 @@ function snapshotInstall(install, renamePath = fs.renameSync) {
|
|
|
200
436
|
const pnpmPublicLinkPath = publicLink?.path ?? (entry.isSymbolicLink() ? packageRoot : undefined);
|
|
201
437
|
if (backupKind === "pnpm-public-link" && !pnpmPublicLinkPath)
|
|
202
438
|
return null;
|
|
439
|
+
const npmBinLinks = backupKind === "package" && install.manager === "npm" && install.prefix
|
|
440
|
+
? snapshotNpmBinLinks(install.prefix, sourcePath)
|
|
441
|
+
: undefined;
|
|
203
442
|
movedPath = backupKind === "pnpm-public-link" ? pnpmPublicLinkPath : sourcePath;
|
|
204
443
|
backupPath = path.join(path.dirname(movedPath), `.hq-cli-backup-${process.pid}-${randomBytes(8).toString("hex")}`);
|
|
205
444
|
renamePath(movedPath, backupPath);
|
|
@@ -215,6 +454,9 @@ function snapshotInstall(install, renamePath = fs.renameSync) {
|
|
|
215
454
|
publicLinkTarget: backupKind === "pnpm-public-link"
|
|
216
455
|
? publicLink?.target ?? symlinkTarget
|
|
217
456
|
: publicLink?.target,
|
|
457
|
+
npmBinDirectory: npmBinLinks ? path.join(install.prefix, "bin") : undefined,
|
|
458
|
+
npmBinPackageRoot: npmBinLinks ? sourcePath : undefined,
|
|
459
|
+
npmBinLinks,
|
|
218
460
|
};
|
|
219
461
|
}
|
|
220
462
|
catch {
|
|
@@ -260,7 +502,7 @@ function restoreInstall(snapshot, renamePath = fs.renameSync) {
|
|
|
260
502
|
fs.mkdirSync(path.dirname(snapshot.publicLinkPath), { recursive: true });
|
|
261
503
|
fs.symlinkSync(snapshot.publicLinkTarget, snapshot.publicLinkPath, process.platform === "win32" ? "junction" : "dir");
|
|
262
504
|
}
|
|
263
|
-
return
|
|
505
|
+
return restoreNpmBinLinks(snapshot);
|
|
264
506
|
}
|
|
265
507
|
catch {
|
|
266
508
|
reportFailedRestore(snapshot);
|
|
@@ -458,6 +700,11 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
458
700
|
else {
|
|
459
701
|
result = runner(plan.cmd, plan.args, updateEnv);
|
|
460
702
|
}
|
|
703
|
+
if (result.ok && !staged && install.manager === "npm" && install.prefix && install.packageRoot) {
|
|
704
|
+
const bins = verifyOrRepairNpmBins(install.prefix, install.packageRoot);
|
|
705
|
+
if (!bins.ok)
|
|
706
|
+
result = bins;
|
|
707
|
+
}
|
|
461
708
|
if (!result.ok && installSnapshot) {
|
|
462
709
|
restoredAfterFailure = restoreInstall(installSnapshot, renamePath);
|
|
463
710
|
if (restoredAfterFailure)
|
|
@@ -89,7 +89,7 @@ export function stagedNpmInstall(install, version, deps = {}) {
|
|
|
89
89
|
}
|
|
90
90
|
return { ok: false, detail: `could not move hq-cli ${version} into place: ${message(error)}` };
|
|
91
91
|
}
|
|
92
|
-
if (!linkBins(prefix, live, readBins(backup), tag)) {
|
|
92
|
+
if (!linkBins(prefix, live, readBins(backup), tag, rename)) {
|
|
93
93
|
// `hq` does not reach the new package, so put the old one back.
|
|
94
94
|
try {
|
|
95
95
|
rename(live, staged);
|
|
@@ -99,10 +99,10 @@ export function stagedNpmInstall(install, version, deps = {}) {
|
|
|
99
99
|
keepBackup = true;
|
|
100
100
|
return {
|
|
101
101
|
ok: false,
|
|
102
|
-
detail: `could not
|
|
102
|
+
detail: `could not verify or relink every declared hq-cli bin for ${version}, or restore the previous install from ${backup} (${message(error)})`,
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
|
-
return { ok: false, detail: `could not
|
|
105
|
+
return { ok: false, detail: `could not verify or relink every declared hq-cli bin for ${version}` };
|
|
106
106
|
}
|
|
107
107
|
try {
|
|
108
108
|
postinstall(live);
|
|
@@ -136,11 +136,11 @@ export function stagedNpmInstall(install, version, deps = {}) {
|
|
|
136
136
|
* links already point at the live path. A link into another package, or a
|
|
137
137
|
* regular file (someone's wrapper), is left alone.
|
|
138
138
|
*
|
|
139
|
-
* `hq`
|
|
140
|
-
*
|
|
141
|
-
*
|
|
139
|
+
* `hq` must resolve into the live package or the caller restores the old
|
|
140
|
+
* package. Other declared bins owned by another tool are left alone and warned
|
|
141
|
+
* about; bins we own or need to create must resolve into the live package.
|
|
142
142
|
*/
|
|
143
|
-
function linkBins(prefix, live, previousBins, tag) {
|
|
143
|
+
function linkBins(prefix, live, previousBins, tag, rename) {
|
|
144
144
|
const binDir = path.join(prefix, "bin");
|
|
145
145
|
const ownedByHqCli = (link) => {
|
|
146
146
|
try {
|
|
@@ -156,7 +156,7 @@ function linkBins(prefix, live, previousBins, tag) {
|
|
|
156
156
|
try {
|
|
157
157
|
fs.mkdirSync(binDir, { recursive: true });
|
|
158
158
|
fs.symlinkSync(want, temporary);
|
|
159
|
-
|
|
159
|
+
rename(temporary, link);
|
|
160
160
|
}
|
|
161
161
|
catch {
|
|
162
162
|
try {
|
|
@@ -185,30 +185,70 @@ function linkBins(prefix, live, previousBins, tag) {
|
|
|
185
185
|
pointAt(link, want);
|
|
186
186
|
};
|
|
187
187
|
const bins = readBins(live);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
188
|
+
if (typeof bins.hq !== "string" || bins.hq.length === 0)
|
|
189
|
+
return false;
|
|
190
|
+
const foreignBins = new Set();
|
|
191
|
+
for (const [name, target] of Object.entries(bins)) {
|
|
192
|
+
if (!name || name === "." || name === ".." || name.includes("/") || name.includes("\\"))
|
|
193
|
+
return false;
|
|
194
|
+
const targetPath = path.resolve(live, target);
|
|
195
|
+
if (!targetPath.startsWith(`${path.resolve(live)}${path.sep}`) || !resolvesToFile(targetPath))
|
|
196
|
+
return false;
|
|
197
|
+
const link = path.join(binDir, name);
|
|
198
|
+
try {
|
|
199
|
+
const existing = fs.lstatSync(link, { throwIfNoEntry: false });
|
|
200
|
+
if (existing && (!existing.isSymbolicLink() || !ownedByHqCli(link))) {
|
|
201
|
+
if (name === "hq")
|
|
202
|
+
return false;
|
|
203
|
+
foreignBins.add(name);
|
|
204
|
+
console.error(`⚠ Leaving hq-cli bin entry at ${link} untouched; another tool owns it.`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const changedBins = [];
|
|
212
|
+
const linkAndRecord = (name, target) => {
|
|
213
|
+
const link = path.join(binDir, name);
|
|
214
|
+
const before = symlinkTarget(link);
|
|
215
|
+
linkBin(name, target);
|
|
216
|
+
const after = symlinkTarget(link);
|
|
217
|
+
if (after !== before)
|
|
218
|
+
changedBins.push({ link, before });
|
|
219
|
+
};
|
|
220
|
+
const rollbackBinChanges = () => {
|
|
221
|
+
for (const change of changedBins.reverse()) {
|
|
222
|
+
const current = symlinkTarget(change.link);
|
|
223
|
+
if (current === null || !ownedByHqCli(change.link))
|
|
224
|
+
continue;
|
|
225
|
+
if (change.before === null) {
|
|
196
226
|
try {
|
|
197
|
-
fs.unlinkSync(
|
|
227
|
+
fs.unlinkSync(change.link);
|
|
198
228
|
}
|
|
199
229
|
catch {
|
|
200
|
-
//
|
|
230
|
+
// The staged update still fails; preserve the best-effort rollback result.
|
|
201
231
|
}
|
|
202
232
|
}
|
|
203
233
|
else {
|
|
204
|
-
pointAt(
|
|
234
|
+
pointAt(change.link, change.before);
|
|
205
235
|
}
|
|
206
236
|
}
|
|
237
|
+
};
|
|
238
|
+
const hqLink = path.join(binDir, "hq");
|
|
239
|
+
linkAndRecord("hq", bins.hq);
|
|
240
|
+
if (!resolvesToPackageBin(hqLink, live, bins.hq)) {
|
|
241
|
+
rollbackBinChanges();
|
|
207
242
|
return false;
|
|
208
243
|
}
|
|
209
244
|
for (const [name, target] of Object.entries(bins)) {
|
|
210
|
-
if (name
|
|
211
|
-
|
|
245
|
+
if (name === "hq" || foreignBins.has(name))
|
|
246
|
+
continue;
|
|
247
|
+
linkAndRecord(name, target);
|
|
248
|
+
if (!resolvesToPackageBin(path.join(binDir, name), live, target)) {
|
|
249
|
+
rollbackBinChanges();
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
212
252
|
}
|
|
213
253
|
for (const name of Object.keys(previousBins)) {
|
|
214
254
|
if (name in bins)
|
|
@@ -240,6 +280,16 @@ function resolvesToFile(link) {
|
|
|
240
280
|
return false;
|
|
241
281
|
}
|
|
242
282
|
}
|
|
283
|
+
function resolvesToPackageBin(link, packageRoot, target) {
|
|
284
|
+
try {
|
|
285
|
+
return fs.lstatSync(link).isSymbolicLink() &&
|
|
286
|
+
fs.realpathSync(link) === fs.realpathSync(path.resolve(packageRoot, target)) &&
|
|
287
|
+
fs.statSync(link).isFile();
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
243
293
|
function readBins(packageRoot) {
|
|
244
294
|
try {
|
|
245
295
|
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
|