@indigoai-us/hq-cli 5.298.0 → 5.299.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 +5 -0
- package/dist/command-catalog.generated.d.ts +23 -0
- package/dist/command-catalog.generated.js +30 -0
- package/dist/commands/daemon.js +1 -0
- package/dist/commands/lanes-accounts.d.ts +3 -0
- package/dist/commands/lanes-accounts.js +25 -1
- package/dist/commands/lanes.js +10 -0
- 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 +12 -1
- package/dist/lib/lanes/store.d.ts +2 -0
- package/dist/lib/lanes/store.js +4 -0
- package/dist/lib/lanes/watcher.js +10 -1
- 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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.299.0] — 2026-09-30
|
|
6
|
+
- Add `hq lanes accounts clear-quota` and clear future quota marks after successful lane turns.
|
|
7
|
+
- On macOS, `hq daemon` retires a loaded legacy mesh LaunchAgent even when its plist is missing, and daemon uninstall boots the job out before deleting its plist. Mesh-owner wait logs now repeat at most every ten minutes.
|
|
8
|
+
- Restore npm command links after a failed hq-cli self-update and repair or report missing links after successful installs.
|
|
9
|
+
|
|
5
10
|
## [5.298.0] — 2026-09-30
|
|
6
11
|
- [feedback_d4a080e9-de79-4290-81c3-06ba26990a38] Preserve raw substring matches while normalizing company path searches.
|
|
7
12
|
- `hq agents` now prints the Slack app settings link and the console paste link whenever an agent is waiting on its Slack app-level token, and follows the pending actions and paste URL the server returns.
|
|
@@ -8990,6 +8990,29 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
8990
8990
|
readonly description: "JSON on stdout (always)";
|
|
8991
8991
|
}];
|
|
8992
8992
|
readonly subcommands: readonly [];
|
|
8993
|
+
}, {
|
|
8994
|
+
readonly name: "clear-quota";
|
|
8995
|
+
readonly description: "Clear quota reset marks for a provider account";
|
|
8996
|
+
readonly aliases: readonly [];
|
|
8997
|
+
readonly hidden: false;
|
|
8998
|
+
readonly usage: "[options] <provider> <account-id>";
|
|
8999
|
+
readonly arguments: readonly [{
|
|
9000
|
+
readonly name: "provider";
|
|
9001
|
+
readonly required: true;
|
|
9002
|
+
readonly variadic: false;
|
|
9003
|
+
}, {
|
|
9004
|
+
readonly name: "account-id";
|
|
9005
|
+
readonly required: true;
|
|
9006
|
+
readonly variadic: false;
|
|
9007
|
+
}];
|
|
9008
|
+
readonly options: readonly [{
|
|
9009
|
+
readonly flags: "--model <model>";
|
|
9010
|
+
readonly description: "Clear only this model's reset";
|
|
9011
|
+
}, {
|
|
9012
|
+
readonly flags: "--json";
|
|
9013
|
+
readonly description: "JSON on stdout (always)";
|
|
9014
|
+
}];
|
|
9015
|
+
readonly subcommands: readonly [];
|
|
8993
9016
|
}, {
|
|
8994
9017
|
readonly name: "enable";
|
|
8995
9018
|
readonly description: "Enable an account without touching credentials";
|
|
@@ -11601,6 +11601,36 @@ export const COMMAND_CATALOG = [
|
|
|
11601
11601
|
],
|
|
11602
11602
|
"subcommands": []
|
|
11603
11603
|
},
|
|
11604
|
+
{
|
|
11605
|
+
"name": "clear-quota",
|
|
11606
|
+
"description": "Clear quota reset marks for a provider account",
|
|
11607
|
+
"aliases": [],
|
|
11608
|
+
"hidden": false,
|
|
11609
|
+
"usage": "[options] <provider> <account-id>",
|
|
11610
|
+
"arguments": [
|
|
11611
|
+
{
|
|
11612
|
+
"name": "provider",
|
|
11613
|
+
"required": true,
|
|
11614
|
+
"variadic": false
|
|
11615
|
+
},
|
|
11616
|
+
{
|
|
11617
|
+
"name": "account-id",
|
|
11618
|
+
"required": true,
|
|
11619
|
+
"variadic": false
|
|
11620
|
+
}
|
|
11621
|
+
],
|
|
11622
|
+
"options": [
|
|
11623
|
+
{
|
|
11624
|
+
"flags": "--model <model>",
|
|
11625
|
+
"description": "Clear only this model's reset"
|
|
11626
|
+
},
|
|
11627
|
+
{
|
|
11628
|
+
"flags": "--json",
|
|
11629
|
+
"description": "JSON on stdout (always)"
|
|
11630
|
+
}
|
|
11631
|
+
],
|
|
11632
|
+
"subcommands": []
|
|
11633
|
+
},
|
|
11604
11634
|
{
|
|
11605
11635
|
"name": "enable",
|
|
11606
11636
|
"description": "Enable an account without touching credentials",
|
package/dist/commands/daemon.js
CHANGED
|
@@ -91,6 +91,7 @@ async function loadDefaults(over) {
|
|
|
91
91
|
home: dir ?? home,
|
|
92
92
|
existsSync: fs.existsSync,
|
|
93
93
|
listDir: (dir) => fs.readdirSync(dir),
|
|
94
|
+
launchdJobLoaded: (label) => run("launchctl", ["print", `gui/${process.getuid?.() ?? 0}/${label}`]).status === 0,
|
|
94
95
|
windowsTaskExists: (task) => run("schtasks.exe", ["/Query", "/TN", task]).status === 0,
|
|
95
96
|
})),
|
|
96
97
|
resolveHqRoot: over.resolveHqRoot ?? ((flag) => resolveDaemonHqRoot(flag, { env, cwd: process.cwd(), home })),
|
|
@@ -17,6 +17,9 @@ export declare function createLaneAccountsHandlers(deps: LaneAccountsHandlerDeps
|
|
|
17
17
|
host?: string;
|
|
18
18
|
company?: string;
|
|
19
19
|
}) => Promise<void>;
|
|
20
|
+
runAccountsClearQuota: (ctx: Ctx, providerRaw: string, accountIdRaw: string, opts: {
|
|
21
|
+
model?: string;
|
|
22
|
+
}) => Promise<void>;
|
|
20
23
|
runAccountsEnable: (ctx: Ctx, providerRaw: string, accountId: string) => Promise<void>;
|
|
21
24
|
runAccountsDisable: (ctx: Ctx, providerRaw: string, accountId: string, opts: {
|
|
22
25
|
reason?: string;
|
|
@@ -8,7 +8,7 @@ import { AccountResolutionError, DEFAULT_ACCOUNT_HOMES, LANE_PROVIDERS, PROVIDER
|
|
|
8
8
|
import { assertUniqueAccountHomes, providerCompactAfterMs } from "../lib/lanes/config.js";
|
|
9
9
|
import { resolveLaneEffort } from "../lib/lanes/effort.js";
|
|
10
10
|
import { LanesError } from "../lib/lanes/errors.js";
|
|
11
|
-
import { accountQuotaView, loadQuotaState } from "../lib/lanes/quota.js";
|
|
11
|
+
import { accountQuotaView, clearAccountQuota, loadQuotaState } from "../lib/lanes/quota.js";
|
|
12
12
|
import { resolveLaneModel } from "../lib/lanes/spawn.js";
|
|
13
13
|
export function createLaneAccountsHandlers(deps) {
|
|
14
14
|
const { automaticBalanceMoveHistoryReason, ensureDir, laneWithRecoveredProviderSession, loadClaudeBalanceHostSnapshots, parseRemoteJson, prepareAccountMove, printJson, reconcileHostFromCtx, resolveCallerPath } = deps;
|
|
@@ -489,6 +489,29 @@ export function createLaneAccountsHandlers(deps) {
|
|
|
489
489
|
home_source: home === account.home ? "default" : "host",
|
|
490
490
|
});
|
|
491
491
|
}
|
|
492
|
+
async function runAccountsClearQuota(ctx, providerRaw, accountIdRaw, opts) {
|
|
493
|
+
const provider = parseLaneProvider(providerRaw);
|
|
494
|
+
const account = accountIdRaw.trim();
|
|
495
|
+
if (!account) {
|
|
496
|
+
throw new AccountResolutionError("unknown_account", "account-id must be non-empty", { provider, account: accountIdRaw, enabled: [] });
|
|
497
|
+
}
|
|
498
|
+
const model = opts.model?.trim();
|
|
499
|
+
if (opts.model !== undefined && !model) {
|
|
500
|
+
throw new LanesError("invalid_model", "--model must be non-empty", {
|
|
501
|
+
provider,
|
|
502
|
+
account,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
const cleared = clearAccountQuota(ctx.hqRoot(), provider, account, model === undefined ? {} : { model });
|
|
506
|
+
printJson({
|
|
507
|
+
ok: true,
|
|
508
|
+
action: "accounts.clear-quota",
|
|
509
|
+
provider,
|
|
510
|
+
account,
|
|
511
|
+
all_models_quota_reset_at: cleared.all_models_quota_reset_at,
|
|
512
|
+
model_quota_resets: cleared.model_quota_resets,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
492
515
|
async function runAccountsEnable(ctx, providerRaw, accountId) {
|
|
493
516
|
const hqRoot = ctx.hqRoot();
|
|
494
517
|
const provider = parseLaneProvider(providerRaw);
|
|
@@ -562,6 +585,7 @@ export function createLaneAccountsHandlers(deps) {
|
|
|
562
585
|
runAccountsBalance,
|
|
563
586
|
runAccountsLogin,
|
|
564
587
|
runAccountsSetHome,
|
|
588
|
+
runAccountsClearQuota,
|
|
565
589
|
runAccountsEnable,
|
|
566
590
|
runAccountsDisable
|
|
567
591
|
};
|
package/dist/commands/lanes.js
CHANGED
|
@@ -8453,6 +8453,16 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
8453
8453
|
.action(wrap(async (provider, accountId, opts) => {
|
|
8454
8454
|
await accountHandlers.runAccountsLogin(ctx, provider, accountId, opts);
|
|
8455
8455
|
}));
|
|
8456
|
+
accounts
|
|
8457
|
+
.command("clear-quota")
|
|
8458
|
+
.description("Clear quota reset marks for a provider account")
|
|
8459
|
+
.argument("<provider>", "claude | codex | grok")
|
|
8460
|
+
.argument("<account-id>", "Account id")
|
|
8461
|
+
.option("--model <model>", "Clear only this model's reset")
|
|
8462
|
+
.option("--json", "JSON on stdout (always)")
|
|
8463
|
+
.action(wrap(async (provider, accountId, opts) => {
|
|
8464
|
+
await accountHandlers.runAccountsClearQuota(ctx, provider, accountId, opts);
|
|
8465
|
+
}));
|
|
8456
8466
|
accounts
|
|
8457
8467
|
.command("enable")
|
|
8458
8468
|
.description("Enable an account without touching credentials")
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* still runs a service, the daemon leaves that service alone instead of
|
|
4
4
|
* starting a second copy, and `hq daemon migrate` lists it.
|
|
5
5
|
*
|
|
6
|
-
* Detection
|
|
7
|
-
*
|
|
6
|
+
* Detection reads unit files and checks for an orphaned loaded macOS mesh job
|
|
7
|
+
* whose plist was removed. Windows scheduled tasks are probed through
|
|
8
|
+
* schtasks. Nothing here stops or removes a unit.
|
|
8
9
|
*/
|
|
9
10
|
import type { ServiceName } from "./catalog.js";
|
|
10
11
|
export interface LegacyUnit {
|
|
@@ -22,6 +23,8 @@ export interface LegacyProbeDeps {
|
|
|
22
23
|
home: string;
|
|
23
24
|
existsSync: (p: string) => boolean;
|
|
24
25
|
listDir: (dir: string) => string[];
|
|
26
|
+
/** True when launchctl can print the GUI-domain job despite a missing plist. */
|
|
27
|
+
launchdJobLoaded: (label: string) => boolean;
|
|
25
28
|
windowsTaskExists: (taskName: string) => boolean;
|
|
26
29
|
}
|
|
27
30
|
export declare function describeLegacyUnit(unit: LegacyUnit): string;
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* still runs a service, the daemon leaves that service alone instead of
|
|
4
4
|
* starting a second copy, and `hq daemon migrate` lists it.
|
|
5
5
|
*
|
|
6
|
-
* Detection
|
|
7
|
-
*
|
|
6
|
+
* Detection reads unit files and checks for an orphaned loaded macOS mesh job
|
|
7
|
+
* whose plist was removed. Windows scheduled tasks are probed through
|
|
8
|
+
* schtasks. Nothing here stops or removes a unit.
|
|
8
9
|
*/
|
|
9
10
|
import * as path from "node:path";
|
|
10
11
|
const KIT_SERVICES = ["sync", "inbox", "heartbeat"];
|
|
@@ -52,8 +53,9 @@ function darwinUnits(deps) {
|
|
|
52
53
|
const units = [];
|
|
53
54
|
const at = (label) => path.join(dir, `${label}.plist`);
|
|
54
55
|
const add = (unitId, service, label, migratable = true) => units.push({ unitId, service, kind: "launchd", name: label, path: at(label), migratable });
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
const meshLabel = "ai.getindigo.hq-mesh-daemon";
|
|
57
|
+
if (deps.existsSync(at(meshLabel)) || deps.launchdJobLoaded(meshLabel))
|
|
58
|
+
add("mesh", "mesh", meshLabel);
|
|
57
59
|
for (const file of safeList(deps, dir).sort()) {
|
|
58
60
|
const m = /^ai\.getindigo\.hq-bot\.(.+)\.plist$/.exec(file);
|
|
59
61
|
if (m)
|
|
@@ -17,11 +17,13 @@
|
|
|
17
17
|
* folder, so nobody but root decides what gets installed there.
|
|
18
18
|
*/
|
|
19
19
|
import type { RunCommand } from "../service-manager/launchd.js";
|
|
20
|
-
import type
|
|
20
|
+
import { type LegacyUnit } from "./legacy.js";
|
|
21
21
|
export interface AdoptedUnit extends LegacyUnit {
|
|
22
22
|
/** Where the unit's definition (plist, unit file, task XML) was moved. */
|
|
23
23
|
movedTo: string;
|
|
24
24
|
adoptedAt: number;
|
|
25
|
+
/** Launchd job was loaded, but its source plist no longer existed. */
|
|
26
|
+
definitionMissing?: true;
|
|
25
27
|
}
|
|
26
28
|
export interface MigrateDeps {
|
|
27
29
|
run: RunCommand;
|
|
@@ -64,5 +66,12 @@ export interface RestoreResult {
|
|
|
64
66
|
}
|
|
65
67
|
export declare function readAdoptedUnits(recordPath: string): AdoptedUnit[];
|
|
66
68
|
export declare function adoptLegacyUnits(units: readonly LegacyUnit[], deps: MigrateDeps): AdoptResult;
|
|
69
|
+
/** Retire the migratable legacy mesh job and emit the daemon's operator-facing result lines. */
|
|
70
|
+
export declare function retireLegacyMeshUnits(units: readonly LegacyUnit[], options: {
|
|
71
|
+
platform: NodeJS.Platform;
|
|
72
|
+
managed: boolean;
|
|
73
|
+
hosted: boolean;
|
|
74
|
+
meshEnabled: boolean;
|
|
75
|
+
}, deps: MigrateDeps, log: (level: "info" | "warn", message: string) => void): AdoptResult;
|
|
67
76
|
export declare function restoreLegacyUnits(deps: MigrateDeps): RestoreResult;
|
|
68
77
|
//# sourceMappingURL=migrate.d.ts.map
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import * as fs from "node:fs";
|
|
20
20
|
import * as path from "node:path";
|
|
21
|
+
import { legacyMeshUnitsToRetire } from "./legacy.js";
|
|
21
22
|
import { ensurePrivateDir } from "./paths.js";
|
|
22
23
|
export function readAdoptedUnits(recordPath) {
|
|
23
24
|
let text;
|
|
@@ -99,8 +100,10 @@ function adoptOne(unit, deps) {
|
|
|
99
100
|
if (bootout.status !== 0 && deps.run("launchctl", ["print", target]).status === 0) {
|
|
100
101
|
must(deps.run, "launchctl", ["bootout", target]);
|
|
101
102
|
}
|
|
103
|
+
if (!fs.existsSync(unit.path))
|
|
104
|
+
return { movedTo: unit.path, definitionMissing: true };
|
|
102
105
|
moveFile(unit.path, to);
|
|
103
|
-
return to;
|
|
106
|
+
return { movedTo: to };
|
|
104
107
|
}
|
|
105
108
|
case "systemd-user":
|
|
106
109
|
case "systemd-system": {
|
|
@@ -109,7 +112,7 @@ function adoptOne(unit, deps) {
|
|
|
109
112
|
must(deps.run, ...systemctl(unit, deps, ["disable", "--now", unit.name]));
|
|
110
113
|
moveFile(unit.path, to);
|
|
111
114
|
deps.run(...systemctl(unit, deps, ["daemon-reload"]));
|
|
112
|
-
return to;
|
|
115
|
+
return { movedTo: to };
|
|
113
116
|
}
|
|
114
117
|
case "schtasks": {
|
|
115
118
|
const xml = must(deps.run, "schtasks.exe", ["/Query", "/TN", unit.name, "/XML"]);
|
|
@@ -117,7 +120,7 @@ function adoptOne(unit, deps) {
|
|
|
117
120
|
fs.writeFileSync(to, encodeTaskXml(xml), { mode: 0o600 });
|
|
118
121
|
deps.run("schtasks.exe", ["/End", "/TN", unit.name]);
|
|
119
122
|
must(deps.run, "schtasks.exe", ["/Delete", "/TN", unit.name, "/F"]);
|
|
120
|
-
return to;
|
|
123
|
+
return { movedTo: to };
|
|
121
124
|
}
|
|
122
125
|
}
|
|
123
126
|
}
|
|
@@ -141,15 +144,15 @@ export function adoptLegacyUnits(units, deps) {
|
|
|
141
144
|
continue;
|
|
142
145
|
}
|
|
143
146
|
const store = storeFor(unit.kind, deps);
|
|
144
|
-
let
|
|
147
|
+
let adoptedFields;
|
|
145
148
|
try {
|
|
146
|
-
|
|
149
|
+
adoptedFields = adoptOne(unit, deps);
|
|
147
150
|
}
|
|
148
151
|
catch (err) {
|
|
149
152
|
result.failed.push({ unit, error: err.message });
|
|
150
153
|
continue;
|
|
151
154
|
}
|
|
152
|
-
const adopted = { ...unit,
|
|
155
|
+
const adopted = { ...unit, ...adoptedFields, adoptedAt: now() };
|
|
153
156
|
const next = [...recordOf(store), adopted];
|
|
154
157
|
try {
|
|
155
158
|
// Record each unit as soon as it is moved, so a failure later in the
|
|
@@ -165,7 +168,7 @@ export function adoptLegacyUnits(units, deps) {
|
|
|
165
168
|
restoreOne(adopted, deps);
|
|
166
169
|
}
|
|
167
170
|
catch (restoreErr) {
|
|
168
|
-
rollback = `; putting it back also failed (${restoreErr.message}), its definition is at ${movedTo}`;
|
|
171
|
+
rollback = `; putting it back also failed (${restoreErr.message}), its definition is at ${adopted.movedTo}`;
|
|
169
172
|
}
|
|
170
173
|
result.failed.push({ unit, error: `could not record the takeover: ${err.message}${rollback}` });
|
|
171
174
|
}
|
|
@@ -192,6 +195,8 @@ function restoreOne(unit, deps) {
|
|
|
192
195
|
case "launchd": {
|
|
193
196
|
if (!unit.path)
|
|
194
197
|
throw new Error(`no plist path recorded for ${unit.name}`);
|
|
198
|
+
if (unit.definitionMissing)
|
|
199
|
+
throw new Error("the original LaunchAgent plist is missing; this unit cannot be restored");
|
|
195
200
|
moveBack(unit, unit.path);
|
|
196
201
|
must(deps.run, "launchctl", ["bootstrap", `gui/${deps.uid}`, unit.path]);
|
|
197
202
|
return;
|
|
@@ -216,6 +221,16 @@ function restoreOne(unit, deps) {
|
|
|
216
221
|
return;
|
|
217
222
|
}
|
|
218
223
|
}
|
|
224
|
+
/** Retire the migratable legacy mesh job and emit the daemon's operator-facing result lines. */
|
|
225
|
+
export function retireLegacyMeshUnits(units, options, deps, log) {
|
|
226
|
+
const selected = legacyMeshUnitsToRetire(units, options);
|
|
227
|
+
const result = adoptLegacyUnits(selected, deps);
|
|
228
|
+
for (const unit of result.adopted)
|
|
229
|
+
log("info", `retired legacy ${unit.unitId} unit ${unit.name}; hq daemon now owns mesh`);
|
|
230
|
+
for (const failure of result.failed)
|
|
231
|
+
log("warn", `could not retire legacy ${failure.unit.unitId} unit ${failure.unit.name}: ${failure.error}`);
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
219
234
|
function restoreFrom(record, deps, check, result) {
|
|
220
235
|
const units = readAdoptedUnits(record);
|
|
221
236
|
if (units.length === 0)
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -22,7 +22,7 @@ import { SUPERVISOR_HANDOFF_FLAG, readDaemonFlag } from "./flag.js";
|
|
|
22
22
|
import { findLegacyUnits, legacyMeshUnitsToRetire, legacyOwnerLookup } from "./legacy.js";
|
|
23
23
|
import { readLegacySyncEnv } from "./legacy-sync-env.js";
|
|
24
24
|
import { acquireDaemonLock, isProcessAlive, processCommandLine, releaseDaemonLock } from "./lock.js";
|
|
25
|
-
import {
|
|
25
|
+
import { retireLegacyMeshUnits } from "./migrate.js";
|
|
26
26
|
import { daemonDir as meshDaemonDir, daemonPidPath as meshDaemonPidPath } from "../mesh/live/daemon/paths.js";
|
|
27
27
|
import { readPidLock } from "../mesh/live/daemon/pid-lock.js";
|
|
28
28
|
import { daemonRootRefusal } from "./install.js";
|
|
@@ -72,6 +72,17 @@ export function probeLegacyUnits(platform, home) {
|
|
|
72
72
|
home,
|
|
73
73
|
existsSync: fs.existsSync,
|
|
74
74
|
listDir: (dir) => fs.readdirSync(dir),
|
|
75
|
+
launchdJobLoaded: (label) => {
|
|
76
|
+
if (platform !== "darwin")
|
|
77
|
+
return false;
|
|
78
|
+
const uid = process.getuid?.() ?? 0;
|
|
79
|
+
const result = spawnSync("launchctl", ["print", `gui/${uid}/${label}`], {
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
windowsHide: true,
|
|
82
|
+
timeout: 15_000,
|
|
83
|
+
});
|
|
84
|
+
return result.status === 0;
|
|
85
|
+
},
|
|
75
86
|
windowsTaskExists,
|
|
76
87
|
});
|
|
77
88
|
}
|
|
@@ -344,7 +355,12 @@ export async function runDaemon(opts) {
|
|
|
344
355
|
if (legacyMesh.length > 0) {
|
|
345
356
|
try {
|
|
346
357
|
const uid = process.getuid?.() ?? 0;
|
|
347
|
-
|
|
358
|
+
retireLegacyMeshUnits(legacyMesh, {
|
|
359
|
+
platform,
|
|
360
|
+
managed: opts.managed === true,
|
|
361
|
+
hosted: opts.host !== undefined,
|
|
362
|
+
meshEnabled: enabledServices(machineType, initial).includes("mesh"),
|
|
363
|
+
}, {
|
|
348
364
|
run: (command, args) => {
|
|
349
365
|
const r = spawnSync(command, args, { encoding: "utf8", windowsHide: true, timeout: 15_000 });
|
|
350
366
|
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
@@ -356,11 +372,7 @@ export async function runDaemon(opts) {
|
|
|
356
372
|
systemAdoptedDir: "/var/lib/hq/daemon/adopted",
|
|
357
373
|
systemRecordPath: "/var/lib/hq/daemon/adopted.json",
|
|
358
374
|
systemUnitDir: "/etc/systemd/system",
|
|
359
|
-
});
|
|
360
|
-
for (const unit of result.adopted)
|
|
361
|
-
log("info", `retired legacy ${unit.unitId} unit ${unit.name}; hq daemon now owns mesh`);
|
|
362
|
-
for (const failure of result.failed)
|
|
363
|
-
log("warn", `could not retire legacy ${failure.unit.unitId} unit ${failure.unit.name}: ${failure.error}`);
|
|
375
|
+
}, (level, message) => log(level, message));
|
|
364
376
|
}
|
|
365
377
|
catch (err) {
|
|
366
378
|
log("error", `could not retire legacy mesh units: ${err.message}`);
|
|
@@ -28,6 +28,7 @@ export const BACKOFF_MAX_MS = 60_000;
|
|
|
28
28
|
export const HEALTHY_RUN_MS = 60_000;
|
|
29
29
|
export const STOP_ESCALATION_MS = 10_000;
|
|
30
30
|
const MESH_OWNER_RECHECK_MS = 15_000;
|
|
31
|
+
const MESH_OWNER_LOG_INTERVAL_MS = 10 * 60_000;
|
|
31
32
|
/** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
|
|
32
33
|
export const HANDOFF_STAGGER_MS = 15_000;
|
|
33
34
|
/** How long a draining unit may take to finish its work before it gets SIGTERM. */
|
|
@@ -297,11 +298,18 @@ export function createSupervisor(deps) {
|
|
|
297
298
|
}
|
|
298
299
|
const meshOwnerReason = slot.plan.service === "mesh" ? deps.meshOwnerReason?.() : undefined;
|
|
299
300
|
if (meshOwnerReason) {
|
|
301
|
+
let lastLoggedReason;
|
|
302
|
+
let lastLoggedAt = Number.NEGATIVE_INFINITY;
|
|
300
303
|
const waitForOwner = (reason) => {
|
|
301
304
|
slot.state.status = "waiting";
|
|
302
305
|
slot.state.reason = reason;
|
|
303
306
|
slot.state.nextRunAt = now() + MESH_OWNER_RECHECK_MS;
|
|
304
|
-
|
|
307
|
+
const at = now();
|
|
308
|
+
if (reason !== lastLoggedReason || at - lastLoggedAt >= MESH_OWNER_LOG_INTERVAL_MS) {
|
|
309
|
+
deps.log("warn", `${reason}; retrying mesh ownership check in ${MESH_OWNER_RECHECK_MS}ms`);
|
|
310
|
+
lastLoggedReason = reason;
|
|
311
|
+
lastLoggedAt = at;
|
|
312
|
+
}
|
|
305
313
|
slot.timer = setT(() => {
|
|
306
314
|
slot.timer = undefined;
|
|
307
315
|
const current = deps.meshOwnerReason?.();
|
|
@@ -89,6 +89,25 @@ export declare function quotaSignalFromFields(fields: {
|
|
|
89
89
|
}, model: string | undefined, now: Date): QuotaSignal | null;
|
|
90
90
|
export declare function readQuotaSignal(runDir: string, model: string | undefined, now: Date, workerOutOffset?: number): QuotaSignal | null;
|
|
91
91
|
export declare function recordAccountQuota(hqRoot: string, provider: LaneProvider, accountId: string, signal: QuotaSignal): QuotaState;
|
|
92
|
+
export interface ClearedModelQuotaReset {
|
|
93
|
+
model: string;
|
|
94
|
+
quota_reset_at: string;
|
|
95
|
+
}
|
|
96
|
+
export interface AccountQuotaClearResult {
|
|
97
|
+
all_models_quota_reset_at: string | null;
|
|
98
|
+
model_quota_resets: ClearedModelQuotaReset[];
|
|
99
|
+
}
|
|
100
|
+
export interface ClearAccountQuotaOptions {
|
|
101
|
+
/** Clear only this model's reset instead of every per-model reset. */
|
|
102
|
+
model?: string;
|
|
103
|
+
/** Clear only marks whose reset time is still ahead of now. */
|
|
104
|
+
futureOnly?: boolean;
|
|
105
|
+
now?: Date;
|
|
106
|
+
}
|
|
107
|
+
/** Clear quota reset marks and persist them with the quota file's atomic writer. */
|
|
108
|
+
export declare function clearAccountQuota(hqRoot: string, provider: LaneProvider, accountId: string, options?: ClearAccountQuotaOptions): AccountQuotaClearResult;
|
|
109
|
+
export declare function clearAccountQuotaAfterSuccessfulTurn(hqRoot: string, provider: LaneProvider, accountId: string, model: string, now: Date): AccountQuotaClearResult;
|
|
110
|
+
export declare function clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot: string, provider: LaneProvider, accountId: string, model: string, now: Date): void;
|
|
92
111
|
export interface QuotaBlock {
|
|
93
112
|
blocked: boolean;
|
|
94
113
|
reason?: "account_quota_exhausted" | "account_model_unavailable";
|
package/dist/lib/lanes/quota.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import * as fs from "node:fs";
|
|
21
21
|
import * as path from "node:path";
|
|
22
|
+
import { withLaneRecordFileLock } from "./store.js";
|
|
22
23
|
export const QUOTA_STATE_REL = path.join("personal", "lanes", "quota.json");
|
|
23
24
|
export const QUOTA_SCHEMA_VERSION = "hq-lanes-quota.v1";
|
|
24
25
|
/**
|
|
@@ -278,40 +279,116 @@ export function readQuotaSignal(runDir, model, now, workerOutOffset = 0) {
|
|
|
278
279
|
return quotaSignalFromFields(fields, model, now);
|
|
279
280
|
}
|
|
280
281
|
export function recordAccountQuota(hqRoot, provider, accountId, signal) {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
282
|
+
return withLaneRecordFileLock(quotaStatePath(hqRoot), () => {
|
|
283
|
+
const state = loadQuotaState(hqRoot);
|
|
284
|
+
const accounts = state.providers[provider] ?? {};
|
|
285
|
+
const current = accounts[accountId] ?? { models: {} };
|
|
286
|
+
const models = { ...current.models };
|
|
287
|
+
if (signal.model) {
|
|
288
|
+
const prior = models[signal.model] ?? {};
|
|
289
|
+
const next = { ...prior };
|
|
290
|
+
if (signal.needs_credits) {
|
|
291
|
+
next.needs_credits = true;
|
|
292
|
+
next.marker = signal.marker ?? `needs credits for ${signal.model}`;
|
|
293
|
+
delete next.quota_reset_at;
|
|
294
|
+
}
|
|
295
|
+
if (signal.quota_reset_at &&
|
|
296
|
+
!signal.needs_credits &&
|
|
297
|
+
shouldReplaceReset(next.quota_reset_at, signal.quota_reset_at)) {
|
|
298
|
+
next.quota_reset_at = signal.quota_reset_at;
|
|
299
|
+
}
|
|
300
|
+
models[signal.model] = next;
|
|
292
301
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
shouldReplaceReset(
|
|
296
|
-
|
|
302
|
+
const account = { ...current, models };
|
|
303
|
+
if (signal.account_wide && signal.quota_reset_at) {
|
|
304
|
+
if (shouldReplaceReset(current.all_models_quota_reset_at, signal.quota_reset_at)) {
|
|
305
|
+
account.all_models_quota_reset_at = signal.quota_reset_at;
|
|
306
|
+
account.reset_source = signal.reset_source ?? "provider";
|
|
307
|
+
}
|
|
308
|
+
else if (current.all_models_quota_reset_at) {
|
|
309
|
+
account.all_models_quota_reset_at = current.all_models_quota_reset_at;
|
|
310
|
+
account.reset_source = current.reset_source ?? "provider";
|
|
311
|
+
}
|
|
297
312
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
313
|
+
accounts[accountId] = account;
|
|
314
|
+
state.providers[provider] = accounts;
|
|
315
|
+
writeQuotaState(hqRoot, state);
|
|
316
|
+
return state;
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
/** Clear quota reset marks and persist them with the quota file's atomic writer. */
|
|
320
|
+
export function clearAccountQuota(hqRoot, provider, accountId, options = {}) {
|
|
321
|
+
return withLaneRecordFileLock(quotaStatePath(hqRoot), () => {
|
|
322
|
+
const cleared = {
|
|
323
|
+
all_models_quota_reset_at: null,
|
|
324
|
+
model_quota_resets: [],
|
|
325
|
+
};
|
|
326
|
+
const state = loadQuotaState(hqRoot);
|
|
327
|
+
const account = state.providers[provider]?.[accountId];
|
|
328
|
+
if (!account)
|
|
329
|
+
return cleared;
|
|
330
|
+
const now = options.now ?? new Date();
|
|
331
|
+
const shouldClear = (reset) => reset !== undefined &&
|
|
332
|
+
(!options.futureOnly || resetStillAhead(reset, now));
|
|
333
|
+
let changed = false;
|
|
334
|
+
const accountWideReset = account.all_models_quota_reset_at;
|
|
335
|
+
if (shouldClear(accountWideReset)) {
|
|
336
|
+
cleared.all_models_quota_reset_at = accountWideReset;
|
|
337
|
+
delete account.all_models_quota_reset_at;
|
|
338
|
+
delete account.reset_source;
|
|
339
|
+
changed = true;
|
|
340
|
+
}
|
|
341
|
+
const modelNames = options.model === undefined
|
|
342
|
+
? Object.keys(account.models).sort()
|
|
343
|
+
: [options.model];
|
|
344
|
+
for (const model of modelNames) {
|
|
345
|
+
const entry = account.models[model];
|
|
346
|
+
const modelReset = entry?.quota_reset_at;
|
|
347
|
+
if (!entry || !shouldClear(modelReset))
|
|
348
|
+
continue;
|
|
349
|
+
cleared.model_quota_resets.push({
|
|
350
|
+
model,
|
|
351
|
+
quota_reset_at: modelReset,
|
|
352
|
+
});
|
|
353
|
+
delete entry.quota_reset_at;
|
|
354
|
+
if (Object.keys(entry).length === 0)
|
|
355
|
+
delete account.models[model];
|
|
356
|
+
changed = true;
|
|
305
357
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
358
|
+
if (!changed)
|
|
359
|
+
return cleared;
|
|
360
|
+
if (account.all_models_quota_reset_at === undefined) {
|
|
361
|
+
delete account.reset_source;
|
|
309
362
|
}
|
|
363
|
+
const accounts = state.providers[provider];
|
|
364
|
+
if (account.all_models_quota_reset_at === undefined &&
|
|
365
|
+
Object.keys(account.models).length === 0) {
|
|
366
|
+
delete accounts[accountId];
|
|
367
|
+
if (Object.keys(accounts).length === 0)
|
|
368
|
+
delete state.providers[provider];
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
accounts[accountId] = account;
|
|
372
|
+
}
|
|
373
|
+
writeQuotaState(hqRoot, state);
|
|
374
|
+
return cleared;
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
export function clearAccountQuotaAfterSuccessfulTurn(hqRoot, provider, accountId, model, now) {
|
|
378
|
+
return clearAccountQuota(hqRoot, provider, accountId, {
|
|
379
|
+
model,
|
|
380
|
+
futureOnly: true,
|
|
381
|
+
now,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
export function clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot, provider, accountId, model, now) {
|
|
385
|
+
try {
|
|
386
|
+
clearAccountQuotaAfterSuccessfulTurn(hqRoot, provider, accountId, model, now);
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
390
|
+
process.stderr.write(`hq lanes: quota cleanup after successful turn failed for provider=${provider} account=${accountId} model=${model}: ${detail}\n`);
|
|
310
391
|
}
|
|
311
|
-
accounts[accountId] = account;
|
|
312
|
-
state.providers[provider] = accounts;
|
|
313
|
-
writeQuotaState(hqRoot, state);
|
|
314
|
-
return state;
|
|
315
392
|
}
|
|
316
393
|
function shouldReplaceReset(current, incoming) {
|
|
317
394
|
if (!current)
|
|
@@ -27,7 +27,7 @@ import { appendDropboxMessage } from "./dropbox.js";
|
|
|
27
27
|
import { DEFAULT_RUNAWAY_SEARCH_GUARD_CONFIG, listHostProcesses, runRunawayToolGuard, RUNAWAY_TOOL_GUARD_FLAG, } from "./runaway-tool-guard.js";
|
|
28
28
|
import { LEAD_LANE_KEEP_ALIVE_FLAG, LEAD_LANE_KEEP_ALIVE_RESUME_BLOCKED_FLAG, LEAD_LANE_KEEP_ALIVE_BLOCKED_GRACE_MS, LEAD_LANE_KEEP_ALIVE_BLOCKED_RESUME_TEXT, queueLaneTerminalResume, LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT, LEAD_LANE_KEEP_ALIVE_MAX_RESUMES, LEAD_LANE_KEEP_ALIVE_RESUME_TEXT, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, doneKeepAliveVerdict, hasPendingKeepAliveWork, keepAliveExclusion, loadRetiredLaneIds, leadLaneKeepAliveBackoffMs, undeliveredAnsweredQuestions, } from "./keep-alive.js";
|
|
29
29
|
import { providerSupportsSessionResume } from "./delivery.js";
|
|
30
|
-
import { applyDefaultQuotaHold, readQuotaSignal, recordAccountQuota } from "./quota.js";
|
|
30
|
+
import { applyDefaultQuotaHold, clearAccountQuotaAfterSuccessfulTurnBestEffort, readQuotaSignal, recordAccountQuota, } from "./quota.js";
|
|
31
31
|
import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
|
|
32
32
|
import { SILENCE_MS, appendWatcherEvent, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
|
|
33
33
|
import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
|
|
@@ -1299,6 +1299,17 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
|
|
|
1299
1299
|
}
|
|
1300
1300
|
return next;
|
|
1301
1301
|
}, deps.dryRun);
|
|
1302
|
+
if (!deps.dryRun &&
|
|
1303
|
+
result.changed &&
|
|
1304
|
+
result.record.state === "done" &&
|
|
1305
|
+
terminalClassification.state === "done" &&
|
|
1306
|
+
terminalEnvelope?.decision === "done" &&
|
|
1307
|
+
!quota &&
|
|
1308
|
+
!providerRejection &&
|
|
1309
|
+
result.record.account &&
|
|
1310
|
+
result.record.model) {
|
|
1311
|
+
clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot, result.record.provider, result.record.account, result.record.model, new Date(nowIso));
|
|
1312
|
+
}
|
|
1302
1313
|
if (result.changed) {
|
|
1303
1314
|
summary.dead_host.push({
|
|
1304
1315
|
id: lane.lane_id,
|
|
@@ -43,6 +43,8 @@ export declare function sanitizeHostComponent(hostname: string): string;
|
|
|
43
43
|
export declare function mintLaneId(deps?: Pick<StoreDeps, "now" | "hostname"> & Partial<Pick<StoreDeps, "generateLaneUlid">>): string;
|
|
44
44
|
export declare function mintQuestionId(deps?: Pick<StoreDeps, "now" | "hostname">): string;
|
|
45
45
|
export declare function appendCappedHistory<T>(history: readonly T[], entry: T, cap?: number): T[];
|
|
46
|
+
/** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
|
|
47
|
+
export declare function withLaneRecordFileLock<T>(recordPath: string, fn: () => T): T;
|
|
46
48
|
export declare function parseLaneRecord(raw: unknown, filePath?: string): LaneRecord;
|
|
47
49
|
export declare function parseQuestionRecord(raw: unknown, filePath?: string): QuestionRecord;
|
|
48
50
|
/**
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -222,6 +222,10 @@ function withRecordLock(recordPath, deps, fn) {
|
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
|
+
/** Run a lane-owned file read/modify/write transaction under its sidecar lock. */
|
|
226
|
+
export function withLaneRecordFileLock(recordPath, fn) {
|
|
227
|
+
return withRecordLock(recordPath, defaultStoreDeps(), fn);
|
|
228
|
+
}
|
|
225
229
|
function readJsonFile(filePath, deps) {
|
|
226
230
|
let text;
|
|
227
231
|
try {
|
|
@@ -40,7 +40,7 @@ import { laneStories, terminalEnvelopeStoryStatus } from "./stories.js";
|
|
|
40
40
|
import { appendCappedHistory, listQuestionIds, readLane, readQuestion, retryUpdateLane, withLaneHireLocks } from "./store.js";
|
|
41
41
|
import { enforceRegistryApprovalGate } from "./approval-gate.js";
|
|
42
42
|
import { readWorkerRunResult } from "./delivery.js";
|
|
43
|
-
import { applyDefaultQuotaHold, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
|
|
43
|
+
import { applyDefaultQuotaHold, clearAccountQuotaAfterSuccessfulTurnBestEffort, quotaSignalFromFields, readQuotaSignal, recordAccountQuota, } from "./quota.js";
|
|
44
44
|
import { PROVIDER_REJECTED_REASON, QUOTA_STATUS, isProviderLoginFailure, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
|
|
45
45
|
import { appendDropboxMessageAtPath, dropboxInboxHasMessages, readToolActivity, toolActivityPath } from "./dropbox.js";
|
|
46
46
|
import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefaultHoldMs } from "./config.js";
|
|
@@ -445,6 +445,15 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
|
|
|
445
445
|
}
|
|
446
446
|
return next;
|
|
447
447
|
});
|
|
448
|
+
if (updated.state === "done" &&
|
|
449
|
+
effectiveClassification.state === "done" &&
|
|
450
|
+
effectiveEnvelope?.decision === "done" &&
|
|
451
|
+
opts.quota == null &&
|
|
452
|
+
opts.providerRejection == null &&
|
|
453
|
+
updated.account &&
|
|
454
|
+
updated.model) {
|
|
455
|
+
clearAccountQuotaAfterSuccessfulTurnBestEffort(hqRoot, updated.provider, updated.account, updated.model, new Date(at));
|
|
456
|
+
}
|
|
448
457
|
notifySlotFreed({
|
|
449
458
|
hqRoot,
|
|
450
459
|
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"));
|