@indigoai-us/hq-cli 5.345.35 → 5.345.37
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/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +2 -1
- package/dist/command-catalog.generated.d.ts +34 -0
- package/dist/command-catalog.generated.js +40 -0
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/cloud.js +6 -5
- package/dist/commands/core.js +1 -1
- package/dist/commands/provider.d.ts +7 -0
- package/dist/commands/provider.js +14 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +7226 -4092
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/core-utils/token-usage-report.js +1 -1
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/index-render/companies.js +2 -1
- package/dist/lib/lanes/story-ledger.d.ts +1 -1
- package/dist/lib/lanes/story-ledger.js +26 -5
- package/dist/lib/provider-launcher.d.ts +11 -0
- package/dist/lib/provider-launcher.js +59 -0
- package/dist/lib/workers-registry/index.js +28 -3
- package/dist/utils/package-use-lease.d.ts +17 -0
- package/dist/utils/package-use-lease.js +36 -0
- package/dist/utils/self-update.js +143 -7
- package/dist/utils/sync-operation-lock.d.ts +37 -0
- package/dist/utils/sync-operation-lock.js +40 -0
- package/package.json +2 -2
|
@@ -15,7 +15,8 @@ function description(root, company, name) {
|
|
|
15
15
|
case "knowledge": return exists(root, `${base}/.git`) ? "Embedded git repo" : `Inline knowledge (${countImmediate(root, base, (n, f) => !isHidden(n) && fs.statSync(f).isDirectory())} subdirs)`;
|
|
16
16
|
case "settings": return `${countImmediate(root, base, (n, f) => !isHidden(n) && fs.statSync(f).isDirectory())} integration(s)`;
|
|
17
17
|
case "policies": return `${countImmediate(root, base, (n) => n.endsWith(".md"))} policy file(s)`;
|
|
18
|
-
|
|
18
|
+
// Match `find -type d`: count actual directories, not symlinks to directories.
|
|
19
|
+
case "projects": return `${countImmediate(root, base, (n, f) => !n.startsWith("_") && !isHidden(n) && fs.lstatSync(f).isDirectory())} project(s)`;
|
|
19
20
|
case "workers": return `${countImmediate(root, base, (n, f) => !isHidden(n) && fs.statSync(f).isDirectory())} worker(s)`;
|
|
20
21
|
case "repos": return `${countImmediate(root, base, (n, f) => { const s = fs.lstatSync(f); return !isHidden(n) && (s.isDirectory() || s.isSymbolicLink()); })} repo link(s)`;
|
|
21
22
|
case "registry": return `Resource registry (${countImmediate(root, `${base}/resources`, (n) => n.endsWith(".yaml"))} resource(s))`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type LaneRecord, type LaneState } from "./types.js";
|
|
2
2
|
export declare const LANE_STORY_LEDGER_FLAG = "lanes.story-ledger";
|
|
3
|
-
declare const STORY_STATES: readonly ["todo", "in_progress", "
|
|
3
|
+
declare const STORY_STATES: readonly ["todo", "in_progress", "review", "waiting", "blocked", "done", "dropped"];
|
|
4
4
|
export interface ProjectStoryLedger {
|
|
5
5
|
projectId: string;
|
|
6
6
|
companySlug?: string;
|
|
@@ -8,8 +8,8 @@ export const LANE_STORY_LEDGER_FLAG = "lanes.story-ledger";
|
|
|
8
8
|
const LOCK_TIMEOUT_MS = 10_000;
|
|
9
9
|
const LOCK_STALE_MS = 60_000;
|
|
10
10
|
const LOCK_RETRY_MS = 10;
|
|
11
|
-
const STORY_STATES = ["todo", "in_progress", "
|
|
12
|
-
const MANAGER_OWNED_STATES = new Set(["
|
|
11
|
+
const STORY_STATES = ["todo", "in_progress", "review", "waiting", "blocked", "done", "dropped"];
|
|
12
|
+
const MANAGER_OWNED_STATES = new Set(["review", "done", "dropped"]);
|
|
13
13
|
const TERMINAL_STATES = new Set(["done", "failed", "interrupted", "cancelled", "killed"]);
|
|
14
14
|
export function laneStoryLedgerEnabled() {
|
|
15
15
|
return resolveProcessFlagGate(LANE_STORY_LEDGER_FLAG, { fallback: false }, () => false);
|
|
@@ -515,7 +515,7 @@ function validPriorQuestions(value, storyId) {
|
|
|
515
515
|
}
|
|
516
516
|
return value;
|
|
517
517
|
}
|
|
518
|
-
function setLaneStatusFields(story, lane, laneStoryStatus) {
|
|
518
|
+
function setLaneStatusFields(story, lane, laneStoryStatus, isCurrentAttribution) {
|
|
519
519
|
const laneStatus = story.lane_status;
|
|
520
520
|
if (laneStatus !== undefined && !isJsonObject(laneStatus)) {
|
|
521
521
|
throw new LanesError("story_ledger_invalid", "lane_status must be an object for story " + String(story.id), { story_id: story.id });
|
|
@@ -569,6 +569,9 @@ function setLaneStatusFields(story, lane, laneStoryStatus) {
|
|
|
569
569
|
else {
|
|
570
570
|
delete story.pending_questions;
|
|
571
571
|
}
|
|
572
|
+
if (story.state === "in_review" || story.state === "verification") {
|
|
573
|
+
story.state = "review";
|
|
574
|
+
}
|
|
572
575
|
if (MANAGER_OWNED_STATES.has(String(story.state))) {
|
|
573
576
|
// A manager decision always wins over lane-derived status.
|
|
574
577
|
}
|
|
@@ -576,7 +579,7 @@ function setLaneStatusFields(story, lane, laneStoryStatus) {
|
|
|
576
579
|
story.state = "blocked";
|
|
577
580
|
}
|
|
578
581
|
else if (laneStoryStatus === "review") {
|
|
579
|
-
story.state = "
|
|
582
|
+
story.state = "review";
|
|
580
583
|
}
|
|
581
584
|
else if (laneStoryStatus === "done") {
|
|
582
585
|
story.state = "done";
|
|
@@ -590,6 +593,22 @@ function setLaneStatusFields(story, lane, laneStoryStatus) {
|
|
|
590
593
|
? previous
|
|
591
594
|
: "in_progress";
|
|
592
595
|
}
|
|
596
|
+
if (typeof story.state === "string" && STORY_STATES.includes(story.state)) {
|
|
597
|
+
story.status = story.state;
|
|
598
|
+
}
|
|
599
|
+
if (story.state === "blocked") {
|
|
600
|
+
const reasons = blockers.map((entry) => {
|
|
601
|
+
const reason = typeof entry.blocked_on === "string" ? entry.blocked_on.trim() : "";
|
|
602
|
+
return reason || `Lane ${String(entry.lane_id)} is blocked`;
|
|
603
|
+
});
|
|
604
|
+
if (reasons.length > 0)
|
|
605
|
+
story.blocked_reason = [...new Set(reasons)].join("\n");
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
delete story.blocked_reason;
|
|
609
|
+
}
|
|
610
|
+
if (!isCurrentAttribution && story.lane === lane.lane_id)
|
|
611
|
+
delete story.lane;
|
|
593
612
|
if (blockers.length > 0)
|
|
594
613
|
story.lane_status = { saved };
|
|
595
614
|
else
|
|
@@ -611,7 +630,7 @@ function updateOneStory(current, ledger, lane, storyId, projectId, questionRefs)
|
|
|
611
630
|
(laneStoriesForLedger(lane).length <= 1 ? lane.last_envelope?.blocked_reason : undefined);
|
|
612
631
|
const laneState = storyLaneState(lane, laneStory?.status, pendingQuestionIds);
|
|
613
632
|
setLaneEntry(story, lane, laneState, laneStory?.status, prLinks, pendingQuestionIds, blockedReason);
|
|
614
|
-
setLaneStatusFields(story, lane, laneStory?.status);
|
|
633
|
+
setLaneStatusFields(story, lane, laneStory?.status, lane.project_id === projectId && lane.story_id === storyId);
|
|
615
634
|
if (prLinks.length > 0) {
|
|
616
635
|
if (story.prs === undefined)
|
|
617
636
|
story.prs = [];
|
|
@@ -695,6 +714,8 @@ function storyLaneState(lane, laneStoryStatus, pendingQuestionIds) {
|
|
|
695
714
|
}
|
|
696
715
|
function storyState(story) {
|
|
697
716
|
const state = story.state;
|
|
717
|
+
if (state === "in_review" || state === "verification")
|
|
718
|
+
return "review";
|
|
698
719
|
if (typeof state === "string" && STORY_STATES.includes(state)) {
|
|
699
720
|
return state;
|
|
700
721
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import type { Writable } from "node:stream";
|
|
3
|
+
export type ProviderName = "claude" | "codex";
|
|
4
|
+
export interface ProviderLauncherDependencies {
|
|
5
|
+
spawn?: typeof nodeSpawn;
|
|
6
|
+
stdout?: Writable;
|
|
7
|
+
stderr?: Writable;
|
|
8
|
+
}
|
|
9
|
+
export declare function providerLoginGuidance(provider: ProviderName, output: string): string | null;
|
|
10
|
+
export declare function runProviderLauncher(providerValue: string, args: readonly string[], dependencies?: ProviderLauncherDependencies): Promise<number>;
|
|
11
|
+
//# sourceMappingURL=provider-launcher.d.ts.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
export function providerLoginGuidance(provider, output) {
|
|
3
|
+
if (provider === "claude" && /not logged in|please run\s+\/login/i.test(output)) {
|
|
4
|
+
return "Claude Code is not signed in. Sign in with the provider command: claude /login";
|
|
5
|
+
}
|
|
6
|
+
if (provider === "codex" &&
|
|
7
|
+
/(?:http\s*)?401\b/i.test(output) &&
|
|
8
|
+
/missing bearer or basic authentication in header/i.test(output)) {
|
|
9
|
+
return "Codex is not signed in. Sign in with the provider command: codex login";
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
export async function runProviderLauncher(providerValue, args, dependencies = {}) {
|
|
14
|
+
const provider = providerValue;
|
|
15
|
+
if (provider !== "claude" && provider !== "codex") {
|
|
16
|
+
write(dependencies.stderr, `Unsupported provider '${providerValue}'. Choose claude or codex.\n`);
|
|
17
|
+
return 64;
|
|
18
|
+
}
|
|
19
|
+
if (provider === "claude" && !args.some((arg) => arg === "-p" || arg === "--print")) {
|
|
20
|
+
write(dependencies.stderr, "Provider launch supports noninteractive Claude Code commands. Include -p or --print after --.\n");
|
|
21
|
+
return 64;
|
|
22
|
+
}
|
|
23
|
+
if (provider === "codex" && args[0] !== "exec") {
|
|
24
|
+
write(dependencies.stderr, "Provider launch supports noninteractive Codex commands. Start the arguments after -- with exec.\n");
|
|
25
|
+
return 64;
|
|
26
|
+
}
|
|
27
|
+
const child = (dependencies.spawn ?? nodeSpawn)(provider, [...args], {
|
|
28
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
29
|
+
windowsHide: true,
|
|
30
|
+
});
|
|
31
|
+
let outputTail = "";
|
|
32
|
+
const capture = (stream, destination) => {
|
|
33
|
+
if (!stream)
|
|
34
|
+
return;
|
|
35
|
+
stream.on("data", (chunk) => {
|
|
36
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
|
|
37
|
+
outputTail = `${outputTail}${text}`.slice(-16_384);
|
|
38
|
+
destination?.write(text);
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
capture(child.stdout, dependencies.stdout ?? process.stdout);
|
|
42
|
+
capture(child.stderr, dependencies.stderr ?? process.stderr);
|
|
43
|
+
return await new Promise((resolve) => {
|
|
44
|
+
child.once("error", () => {
|
|
45
|
+
write(dependencies.stderr, `Could not start ${provider === "claude" ? "Claude Code" : "Codex"}. Confirm the CLI is installed and available on PATH.\n`);
|
|
46
|
+
resolve(127);
|
|
47
|
+
});
|
|
48
|
+
child.once("close", (code) => {
|
|
49
|
+
const guidance = providerLoginGuidance(provider, outputTail);
|
|
50
|
+
if (guidance)
|
|
51
|
+
write(dependencies.stderr, `${guidance}\n`);
|
|
52
|
+
resolve(code ?? 1);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function write(stream, message) {
|
|
57
|
+
(stream ?? process.stderr).write(message);
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=provider-launcher.js.map
|
|
@@ -241,18 +241,43 @@ export function generateWorkersRegistry(hqRoot, options = {}) {
|
|
|
241
241
|
const output = registryText(options.now ?? new Date(), kept);
|
|
242
242
|
const registry = path.join(hqRoot, 'core/workers/registry.yaml');
|
|
243
243
|
let written = true;
|
|
244
|
+
let writeFailed = false;
|
|
244
245
|
if (fs.existsSync(registry) && withoutTimestamp(fs.readFileSync(registry, 'utf8')) === withoutTimestamp(output))
|
|
245
246
|
written = false;
|
|
246
247
|
if (written) {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
248
|
+
try {
|
|
249
|
+
fs.mkdirSync(path.dirname(registry), { recursive: true });
|
|
250
|
+
fs.writeFileSync(registry, output);
|
|
251
|
+
log('generate-workers-registry: wrote core/workers/registry.yaml');
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
writeFailed = true;
|
|
255
|
+
written = false;
|
|
256
|
+
const code = error instanceof Error && 'code' in error && typeof error.code === 'string'
|
|
257
|
+
? error.code
|
|
258
|
+
: 'unknown';
|
|
259
|
+
log(`generate-workers-registry: WARNING unable to write ${registry} (${code}) — grant write access to the registry path and re-run; a later run will rewrite the derived file once access is restored.`);
|
|
260
|
+
void import('../../sentry.js')
|
|
261
|
+
.then(({ Sentry }) => Sentry.captureMessage('generate-workers-registry: registry write failed; partial result returned', {
|
|
262
|
+
level: 'warning',
|
|
263
|
+
fingerprint: ['workers-registry', 'write-failed'],
|
|
264
|
+
tags: { registry_write_error: code },
|
|
265
|
+
}))
|
|
266
|
+
.catch(() => {
|
|
267
|
+
log('generate-workers-registry: WARNING unable to send the registry write failure report to Sentry.');
|
|
268
|
+
});
|
|
269
|
+
}
|
|
250
270
|
}
|
|
251
271
|
else {
|
|
252
272
|
log('generate-workers-registry: core/workers/registry.yaml unchanged');
|
|
253
273
|
}
|
|
254
274
|
if (quarantined > 0) {
|
|
255
275
|
log(`generate-workers-registry: wrote registry for all VALID workers; quarantined ${quarantined} problem(s) above (loud + partial, never a silent total block). Fix the reported worker.yaml file(s) and re-run to register them.`);
|
|
276
|
+
}
|
|
277
|
+
if (writeFailed) {
|
|
278
|
+
log('generate-workers-registry: registry was not written; the command completed with a partial result.');
|
|
279
|
+
}
|
|
280
|
+
if (quarantined > 0 || writeFailed) {
|
|
256
281
|
return { status: 1, written, quarantined };
|
|
257
282
|
}
|
|
258
283
|
return { status: 0, written, quarantined };
|
|
@@ -50,5 +50,22 @@ export declare const __test__: {
|
|
|
50
50
|
waitForUpdateRequest: typeof waitForUpdateRequest;
|
|
51
51
|
setCurrentPackageUseLease(lease: PackageUseLease | null): void;
|
|
52
52
|
};
|
|
53
|
+
/**
|
|
54
|
+
* Run `wait` with this process's package-use lease DROPPED, then reacquire the
|
|
55
|
+
* lease before returning.
|
|
56
|
+
*
|
|
57
|
+
* `hq sync` runs under purpose `"command"`, and
|
|
58
|
+
* {@link releasePackageUseLeaseAfterCommandLoad} deliberately keeps that lease
|
|
59
|
+
* for the whole command. hq-cloud's per-root operation lock then waits — by
|
|
60
|
+
* default without a bound — for whichever process is already syncing, so an old
|
|
61
|
+
* `hq` install pinned `hq pkg update` for the entire length of somebody else's
|
|
62
|
+
* lock wait (HQ-DESKTOP-8H). Queuing is not using the package: drop the lease
|
|
63
|
+
* while this process is only waiting, and take it again once the wait is over.
|
|
64
|
+
*
|
|
65
|
+
* A reacquire that throws {@link PackageUseUpdatePendingError} is the correct
|
|
66
|
+
* outcome — an update landed while we queued — and propagates to the caller. On
|
|
67
|
+
* the failure path the original error wins and the reacquire is best-effort.
|
|
68
|
+
*/
|
|
69
|
+
export declare function withPackageUseLeaseReleasedDuring<T>(wait: () => Promise<T>, argv?: readonly string[], release?: () => void, reacquire?: (argv: readonly string[]) => unknown): Promise<T>;
|
|
53
70
|
export {};
|
|
54
71
|
//# sourceMappingURL=package-use-lease.d.ts.map
|
|
@@ -413,4 +413,40 @@ export const __test__ = {
|
|
|
413
413
|
currentPackageUseLease = lease;
|
|
414
414
|
},
|
|
415
415
|
};
|
|
416
|
+
/**
|
|
417
|
+
* Run `wait` with this process's package-use lease DROPPED, then reacquire the
|
|
418
|
+
* lease before returning.
|
|
419
|
+
*
|
|
420
|
+
* `hq sync` runs under purpose `"command"`, and
|
|
421
|
+
* {@link releasePackageUseLeaseAfterCommandLoad} deliberately keeps that lease
|
|
422
|
+
* for the whole command. hq-cloud's per-root operation lock then waits — by
|
|
423
|
+
* default without a bound — for whichever process is already syncing, so an old
|
|
424
|
+
* `hq` install pinned `hq pkg update` for the entire length of somebody else's
|
|
425
|
+
* lock wait (HQ-DESKTOP-8H). Queuing is not using the package: drop the lease
|
|
426
|
+
* while this process is only waiting, and take it again once the wait is over.
|
|
427
|
+
*
|
|
428
|
+
* A reacquire that throws {@link PackageUseUpdatePendingError} is the correct
|
|
429
|
+
* outcome — an update landed while we queued — and propagates to the caller. On
|
|
430
|
+
* the failure path the original error wins and the reacquire is best-effort.
|
|
431
|
+
*/
|
|
432
|
+
export async function withPackageUseLeaseReleasedDuring(wait, argv = process.argv, release = releaseCurrentPackageUseLease, reacquire = acquireCurrentPackageUseLease) {
|
|
433
|
+
if (currentPackageUseLease === null)
|
|
434
|
+
return wait();
|
|
435
|
+
release();
|
|
436
|
+
let result;
|
|
437
|
+
try {
|
|
438
|
+
result = await wait();
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
try {
|
|
442
|
+
reacquire(argv);
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
// The caller's error is the one worth reporting.
|
|
446
|
+
}
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
reacquire(argv);
|
|
450
|
+
return result;
|
|
451
|
+
}
|
|
416
452
|
//# sourceMappingURL=package-use-lease.js.map
|
|
@@ -79,10 +79,11 @@ export const REEXEC_GUARD_ENV = UPDATE_RECURSION_GUARD_ENV;
|
|
|
79
79
|
const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(CLI_NAME)}/latest`;
|
|
80
80
|
const FETCH_TIMEOUT_MS = 3_000;
|
|
81
81
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
82
|
+
* Back up the working CLI package before asking the package manager to replace
|
|
83
|
+
* it. If the backup is unavailable, the caller proceeds without rollback
|
|
84
|
+
* protection. pnpm's .pnpm package tree is immutable store content, so back up
|
|
85
|
+
* its selecting public link while leaving that link available for pnpm's scan
|
|
86
|
+
* of existing global installs.
|
|
86
87
|
*/
|
|
87
88
|
function findPnpmPublicLink(packageRoot) {
|
|
88
89
|
const home = derivePnpmHome({ manager: "pnpm", prefix: null, packageRoot });
|
|
@@ -187,6 +188,114 @@ function snapshotNpmBinLinks(prefix, packageRoot) {
|
|
|
187
188
|
}
|
|
188
189
|
return links;
|
|
189
190
|
}
|
|
191
|
+
function snapshotPnpmBinShims(pnpmHome, packageRoot) {
|
|
192
|
+
const binMap = readNpmBinMap(packageRoot);
|
|
193
|
+
if (!binMap)
|
|
194
|
+
return [];
|
|
195
|
+
const binDirectory = path.join(pnpmHome, "bin");
|
|
196
|
+
const shims = [];
|
|
197
|
+
for (const name of Object.keys(binMap)) {
|
|
198
|
+
for (const suffix of ["", ".cmd", ".ps1"]) {
|
|
199
|
+
const shimPath = path.join(binDirectory, `${name}${suffix}`);
|
|
200
|
+
try {
|
|
201
|
+
const stat = fs.lstatSync(shimPath);
|
|
202
|
+
if (!stat.isFile())
|
|
203
|
+
continue;
|
|
204
|
+
const content = fs.readFileSync(shimPath);
|
|
205
|
+
if (!pnpmShimTargetsPackage(content.toString("utf8"), packageRoot))
|
|
206
|
+
continue;
|
|
207
|
+
shims.push({ path: shimPath, content, mode: stat.mode & 0o777 });
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// A missing or unreadable command shim cannot be safely snapshotted.
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return shims;
|
|
215
|
+
}
|
|
216
|
+
function pnpmShimTarget(content) {
|
|
217
|
+
const match = content.match(/^# cmd-shim-target=(.+)$/m);
|
|
218
|
+
return match?.[1]?.trim() || null;
|
|
219
|
+
}
|
|
220
|
+
function pnpmShimTargetsPackage(content, packageRoot) {
|
|
221
|
+
const target = pnpmShimTarget(content);
|
|
222
|
+
if (!target)
|
|
223
|
+
return false;
|
|
224
|
+
try {
|
|
225
|
+
return isPathInside(fs.realpathSync(target), packageRoot);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function pnpmShimTargetsGlobalInstall(content, binDirectory) {
|
|
232
|
+
const target = pnpmShimTarget(content);
|
|
233
|
+
if (!target)
|
|
234
|
+
return false;
|
|
235
|
+
return isPathInside(path.resolve(target), path.join(path.dirname(binDirectory), "global"));
|
|
236
|
+
}
|
|
237
|
+
function restorePnpmBinShims(snapshot) {
|
|
238
|
+
if (!snapshot.pnpmBinDirectory || !snapshot.pnpmBinPackageRoot || !snapshot.pnpmBinShims?.length)
|
|
239
|
+
return true;
|
|
240
|
+
let restored = true;
|
|
241
|
+
for (const shim of snapshot.pnpmBinShims) {
|
|
242
|
+
if (path.dirname(shim.path) !== snapshot.pnpmBinDirectory) {
|
|
243
|
+
reportBinLinkConflict(shim.path, "the saved pnpm shim path is outside the pnpm bin directory");
|
|
244
|
+
restored = false;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
let existing;
|
|
248
|
+
try {
|
|
249
|
+
existing = fs.lstatSync(shim.path);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (error.code !== "ENOENT") {
|
|
253
|
+
reportBinLinkConflict(shim.path, errorMessage(error));
|
|
254
|
+
restored = false;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (existing) {
|
|
259
|
+
if (!existing.isFile() || existing.isSymbolicLink()) {
|
|
260
|
+
reportBinLinkConflict(shim.path, "another entry owns this command name");
|
|
261
|
+
restored = false;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const currentText = fs.readFileSync(shim.path, "utf8");
|
|
265
|
+
if (!pnpmShimTargetsGlobalInstall(currentText, snapshot.pnpmBinDirectory)) {
|
|
266
|
+
reportBinLinkConflict(shim.path, "the existing file is not a pnpm global command shim");
|
|
267
|
+
restored = false;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const temporary = `${shim.path}.hq-cli-restore-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
272
|
+
try {
|
|
273
|
+
fs.mkdirSync(snapshot.pnpmBinDirectory, { recursive: true });
|
|
274
|
+
fs.writeFileSync(temporary, shim.content, { mode: shim.mode, flag: "wx" });
|
|
275
|
+
if (!existing) {
|
|
276
|
+
fs.linkSync(temporary, shim.path);
|
|
277
|
+
fs.rmSync(temporary, { force: true });
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
const current = fs.lstatSync(shim.path);
|
|
281
|
+
if (!current.isFile() || current.isSymbolicLink()) {
|
|
282
|
+
throw new Error("the pnpm shim changed while it was being restored");
|
|
283
|
+
}
|
|
284
|
+
const currentText = fs.readFileSync(shim.path, "utf8");
|
|
285
|
+
if (!pnpmShimTargetsGlobalInstall(currentText, snapshot.pnpmBinDirectory)) {
|
|
286
|
+
throw new Error("the pnpm shim changed while it was being restored");
|
|
287
|
+
}
|
|
288
|
+
fs.renameSync(temporary, shim.path);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
fs.rmSync(temporary, { force: true });
|
|
293
|
+
reportBinLinkConflict(shim.path, errorMessage(error));
|
|
294
|
+
restored = false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return restored;
|
|
298
|
+
}
|
|
190
299
|
function restoreNpmBinLinks(snapshot) {
|
|
191
300
|
if (!snapshot.npmBinDirectory || !snapshot.npmBinPackageRoot || !snapshot.npmBinLinks?.length)
|
|
192
301
|
return true;
|
|
@@ -391,12 +500,16 @@ function snapshotPathsAreSafe(snapshot) {
|
|
|
391
500
|
snapshot.resolvedTarget,
|
|
392
501
|
snapshot.publicLinkPath,
|
|
393
502
|
snapshot.npmBinPackageRoot,
|
|
503
|
+
snapshot.pnpmBinPackageRoot,
|
|
394
504
|
].filter((value) => value !== undefined);
|
|
395
505
|
if (!paths.every(isInsideNodeModules))
|
|
396
506
|
return false;
|
|
397
507
|
if (snapshot.npmBinLinks?.some((bin) => !snapshot.npmBinDirectory || path.dirname(bin.path) !== snapshot.npmBinDirectory ||
|
|
398
508
|
!isPathInside(bin.path, snapshot.npmBinDirectory)))
|
|
399
509
|
return false;
|
|
510
|
+
if (snapshot.pnpmBinShims?.some((shim) => !snapshot.pnpmBinDirectory || path.dirname(shim.path) !== snapshot.pnpmBinDirectory ||
|
|
511
|
+
!isPathInside(shim.path, snapshot.pnpmBinDirectory)))
|
|
512
|
+
return false;
|
|
400
513
|
return true;
|
|
401
514
|
}
|
|
402
515
|
function snapshotInstall(install, renamePath = fs.renameSync) {
|
|
@@ -406,6 +519,7 @@ function snapshotInstall(install, renamePath = fs.renameSync) {
|
|
|
406
519
|
let movedPath;
|
|
407
520
|
let backupPath;
|
|
408
521
|
let didMove = false;
|
|
522
|
+
let copiedPnpmLink = false;
|
|
409
523
|
try {
|
|
410
524
|
const entry = fs.lstatSync(packageRoot);
|
|
411
525
|
let sourcePath = packageRoot;
|
|
@@ -440,9 +554,25 @@ function snapshotInstall(install, renamePath = fs.renameSync) {
|
|
|
440
554
|
const npmBinLinks = backupKind === "package" && install.manager === "npm" && install.prefix
|
|
441
555
|
? snapshotNpmBinLinks(install.prefix, sourcePath)
|
|
442
556
|
: undefined;
|
|
557
|
+
const pnpmHome = backupKind === "pnpm-public-link"
|
|
558
|
+
? derivePnpmHome({ manager: "pnpm", prefix: null, packageRoot: sourcePath }) ?? undefined
|
|
559
|
+
: undefined;
|
|
560
|
+
const pnpmBinShims = pnpmHome ? snapshotPnpmBinShims(pnpmHome, sourcePath) : undefined;
|
|
443
561
|
movedPath = backupKind === "pnpm-public-link" ? pnpmPublicLinkPath : sourcePath;
|
|
444
562
|
backupPath = path.join(path.dirname(movedPath), `.hq-cli-backup-${process.pid}-${randomBytes(8).toString("hex")}`);
|
|
445
|
-
|
|
563
|
+
if (backupKind === "pnpm-public-link") {
|
|
564
|
+
// `pnpm add -g` scans every existing global package before it can update
|
|
565
|
+
// this one. Moving the active link makes its package.json disappear
|
|
566
|
+
// during that scan, so preserve a duplicate link for rollback instead.
|
|
567
|
+
const backupTarget = publicLink?.target ?? resolvedTarget ?? symlinkTarget;
|
|
568
|
+
if (!backupTarget)
|
|
569
|
+
return null;
|
|
570
|
+
fs.symlinkSync(backupTarget, backupPath, process.platform === "win32" ? "junction" : "dir");
|
|
571
|
+
copiedPnpmLink = true;
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
renamePath(movedPath, backupPath);
|
|
575
|
+
}
|
|
446
576
|
didMove = true;
|
|
447
577
|
return {
|
|
448
578
|
installPath: packageRoot,
|
|
@@ -458,12 +588,18 @@ function snapshotInstall(install, renamePath = fs.renameSync) {
|
|
|
458
588
|
npmBinDirectory: npmBinLinks ? path.join(install.prefix, "bin") : undefined,
|
|
459
589
|
npmBinPackageRoot: npmBinLinks ? sourcePath : undefined,
|
|
460
590
|
npmBinLinks,
|
|
591
|
+
pnpmBinDirectory: pnpmBinShims?.length ? path.join(pnpmHome, "bin") : undefined,
|
|
592
|
+
pnpmBinPackageRoot: pnpmBinShims?.length ? sourcePath : undefined,
|
|
593
|
+
pnpmBinShims,
|
|
461
594
|
};
|
|
462
595
|
}
|
|
463
596
|
catch {
|
|
464
597
|
if (didMove && movedPath && backupPath) {
|
|
465
598
|
try {
|
|
466
|
-
|
|
599
|
+
if (copiedPnpmLink)
|
|
600
|
+
fs.rmSync(backupPath, { recursive: true, force: true });
|
|
601
|
+
else
|
|
602
|
+
fs.renameSync(backupPath, movedPath);
|
|
467
603
|
}
|
|
468
604
|
catch {
|
|
469
605
|
// The update may proceed without rollback if the move cannot be undone.
|
|
@@ -483,7 +619,7 @@ function restoreInstall(snapshot, renamePath = fs.renameSync) {
|
|
|
483
619
|
fs.rmSync(publicLinkPath, { recursive: true, force: true });
|
|
484
620
|
fs.mkdirSync(path.dirname(publicLinkPath), { recursive: true });
|
|
485
621
|
renamePath(snapshot.backupPath, publicLinkPath);
|
|
486
|
-
return
|
|
622
|
+
return restorePnpmBinShims(snapshot);
|
|
487
623
|
}
|
|
488
624
|
fs.rmSync(snapshot.installPath, { recursive: true, force: true });
|
|
489
625
|
if (snapshot.symlinkTarget !== undefined && snapshot.resolvedTarget !== undefined) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Take hq-cloud's per-root operation lock for a sync pass from hq-cli, so the
|
|
3
|
+
* wait for an existing holder happens OUTSIDE this process's package-use lease.
|
|
4
|
+
*
|
|
5
|
+
* `sync()` normally wraps itself in `withOperationLock`, which waits for a live
|
|
6
|
+
* holder with no bound unless `HQ_OP_LOCK_TIMEOUT` says otherwise. `hq sync`
|
|
7
|
+
* runs under package-use purpose `"command"`, whose lease is held for the whole
|
|
8
|
+
* command, so the queued process pinned the installed `hq` version — and
|
|
9
|
+
* therefore blocked `hq pkg update` — for the entire length of somebody else's
|
|
10
|
+
* sync (HQ-DESKTOP-8H).
|
|
11
|
+
*
|
|
12
|
+
* Acquiring the lock here instead lets the lease be dropped across the wait and
|
|
13
|
+
* retaken the moment the lock is held, before any sync work runs.
|
|
14
|
+
* `operationLockAlreadyHeld` is the seam hq-cloud exposes for a caller that owns
|
|
15
|
+
* the lock; the lock is NOT reentrant in-process, so the flag is required rather
|
|
16
|
+
* than optional.
|
|
17
|
+
*/
|
|
18
|
+
import { type SyncOptions, type SyncResult } from "@indigoai-us/hq-cloud";
|
|
19
|
+
import { type LockHandle } from "@indigoai-us/hq-cloud/operation-lock";
|
|
20
|
+
export interface SyncUnderOperationLockDeps<Options, Result> {
|
|
21
|
+
sync: (options: Options & {
|
|
22
|
+
operationLockAlreadyHeld: true;
|
|
23
|
+
}) => Promise<Result>;
|
|
24
|
+
/** Injected in tests; defaults to hq-cloud's wait-capable acquisition. */
|
|
25
|
+
acquireLock?: (hqRoot: string) => Promise<LockHandle>;
|
|
26
|
+
/** Injected in tests; defaults to the real lease drop/retake. */
|
|
27
|
+
withLeaseReleased?: <T>(wait: () => Promise<T>) => Promise<T>;
|
|
28
|
+
}
|
|
29
|
+
export declare function syncUnderOperationLock<Options extends {
|
|
30
|
+
hqRoot: string;
|
|
31
|
+
}, Result>(options: Options, deps: SyncUnderOperationLockDeps<Options, Result>): Promise<Result>;
|
|
32
|
+
/**
|
|
33
|
+
* The production sync leg: hq-cloud's `sync()` under a lock this process takes
|
|
34
|
+
* itself. Every `hq sync` path in `src/commands/cloud.ts` calls this.
|
|
35
|
+
*/
|
|
36
|
+
export declare function syncLeg(options: SyncOptions): Promise<SyncResult>;
|
|
37
|
+
//# sourceMappingURL=sync-operation-lock.d.ts.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Take hq-cloud's per-root operation lock for a sync pass from hq-cli, so the
|
|
3
|
+
* wait for an existing holder happens OUTSIDE this process's package-use lease.
|
|
4
|
+
*
|
|
5
|
+
* `sync()` normally wraps itself in `withOperationLock`, which waits for a live
|
|
6
|
+
* holder with no bound unless `HQ_OP_LOCK_TIMEOUT` says otherwise. `hq sync`
|
|
7
|
+
* runs under package-use purpose `"command"`, whose lease is held for the whole
|
|
8
|
+
* command, so the queued process pinned the installed `hq` version — and
|
|
9
|
+
* therefore blocked `hq pkg update` — for the entire length of somebody else's
|
|
10
|
+
* sync (HQ-DESKTOP-8H).
|
|
11
|
+
*
|
|
12
|
+
* Acquiring the lock here instead lets the lease be dropped across the wait and
|
|
13
|
+
* retaken the moment the lock is held, before any sync work runs.
|
|
14
|
+
* `operationLockAlreadyHeld` is the seam hq-cloud exposes for a caller that owns
|
|
15
|
+
* the lock; the lock is NOT reentrant in-process, so the flag is required rather
|
|
16
|
+
* than optional.
|
|
17
|
+
*/
|
|
18
|
+
import { sync } from "@indigoai-us/hq-cloud";
|
|
19
|
+
import { acquireOperationLockAsync, } from "@indigoai-us/hq-cloud/operation-lock";
|
|
20
|
+
import { withPackageUseLeaseReleasedDuring } from "./package-use-lease.js";
|
|
21
|
+
export async function syncUnderOperationLock(options, deps) {
|
|
22
|
+
const acquireLock = deps.acquireLock ?? ((hqRoot) => acquireOperationLockAsync(hqRoot, "sync"));
|
|
23
|
+
const withLeaseReleased = deps.withLeaseReleased ??
|
|
24
|
+
((wait) => withPackageUseLeaseReleasedDuring(wait));
|
|
25
|
+
const handle = await withLeaseReleased(() => acquireLock(options.hqRoot));
|
|
26
|
+
try {
|
|
27
|
+
return await deps.sync({ ...options, operationLockAlreadyHeld: true });
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
handle.release();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The production sync leg: hq-cloud's `sync()` under a lock this process takes
|
|
35
|
+
* itself. Every `hq sync` path in `src/commands/cloud.ts` calls this.
|
|
36
|
+
*/
|
|
37
|
+
export function syncLeg(options) {
|
|
38
|
+
return syncUnderOperationLock(options, { sync });
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=sync-operation-lock.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.345.
|
|
3
|
+
"version": "5.345.37",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
49
49
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
50
50
|
"@aws-sdk/dsql-signer": "^3.1146.0",
|
|
51
|
-
"@indigoai-us/hq-cloud": "~6.18.
|
|
51
|
+
"@indigoai-us/hq-cloud": "~6.18.50",
|
|
52
52
|
"@indigoai-us/hq-flags-client": "^0.1.2",
|
|
53
53
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
54
54
|
"@sentry/node": "^10.49.0",
|