@indigoai-us/hq-cli 5.101.1 → 5.101.3
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/commands/agents.js +5 -0
- package/dist/commands/billing.js +19 -0
- package/dist/commands/outposts.js +5 -1
- package/dist/lib/db/control-plane.js +1 -1
- package/dist/lib/search-index/index.d.ts +31 -1
- package/dist/lib/search-index/index.js +59 -3
- package/dist/main.d.ts +6 -1
- package/dist/main.js +53 -13
- package/dist/sentry.js +12 -0
- package/dist/utils/plan-gate-error.d.ts +11 -2
- package/dist/utils/plan-gate-error.js +39 -5
- package/dist/utils/self-update.d.ts +4 -4
- package/dist/utils/self-update.js +7 -5
- package/dist/utils/team-upgrade.d.ts +17 -0
- package/dist/utils/team-upgrade.js +63 -0
- package/dist/utils/vault-api.js +7 -1
- package/dist/utils/version-gate.d.ts +8 -2
- package/dist/utils/version-gate.js +48 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.101.3] — 2026-08-15
|
|
6
|
+
|
|
7
|
+
## [5.101.2] — 2026-08-15
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Self-update now detects Bun-managed global installs and runs `bun add -g`
|
|
12
|
+
through the owning package manager. Existing pnpm-managed and npm prefix
|
|
13
|
+
installs, including Homebrew prefixes such as `/opt/homebrew`, continue to
|
|
14
|
+
update the copy that is actually running without an unnecessary `sudo`.
|
|
15
|
+
|
|
5
16
|
## [5.101.1] — 2026-08-14
|
|
6
17
|
|
|
7
18
|
### Fixed
|
package/dist/commands/agents.js
CHANGED
|
@@ -27,6 +27,7 @@ import * as readline from "node:readline";
|
|
|
27
27
|
import { resolveVaultCredential } from "../utils/resolve-vault-credential.js";
|
|
28
28
|
import { gateApiKeyCapabilities } from "../utils/api-key-command-gate.js";
|
|
29
29
|
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
30
|
+
import { isPlanGateError } from "../utils/plan-gate-error.js";
|
|
30
31
|
import { confirmChargeOrExit, formatUsd, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
|
|
31
32
|
/** Reasoning-effort values hq-pro accepts on `runtime-config`. */
|
|
32
33
|
export const VALID_EFFORTS = new Set([
|
|
@@ -381,6 +382,10 @@ function sleep(ms) {
|
|
|
381
382
|
// Command registration
|
|
382
383
|
// ---------------------------------------------------------------------------
|
|
383
384
|
function fail(err) {
|
|
385
|
+
// Let the CLI boundary render and offer the one shared Team checkout flow.
|
|
386
|
+
// Local exits would otherwise prevent interactive plan-limit remediation.
|
|
387
|
+
if (isPlanGateError(err))
|
|
388
|
+
throw err;
|
|
384
389
|
if (err instanceof AgentsHttpError) {
|
|
385
390
|
console.error(chalk.red(err.message));
|
|
386
391
|
}
|
package/dist/commands/billing.js
CHANGED
|
@@ -16,6 +16,7 @@ import chalk from "chalk";
|
|
|
16
16
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
17
17
|
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
18
18
|
import { mintPaymentLink } from "../utils/billing-gate.js";
|
|
19
|
+
import { upgradeToTeam } from "../utils/team-upgrade.js";
|
|
19
20
|
function fail(err) {
|
|
20
21
|
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
21
22
|
process.exit(1);
|
|
@@ -107,5 +108,23 @@ export function registerBillingCommand(program) {
|
|
|
107
108
|
fail(err);
|
|
108
109
|
}
|
|
109
110
|
});
|
|
111
|
+
billing
|
|
112
|
+
.command("upgrade")
|
|
113
|
+
.description("Open Stripe Checkout to upgrade this company to HQ Team ($500/mo)")
|
|
114
|
+
.option("--company <slug>", "Company slug (resolves to companyUid)")
|
|
115
|
+
.option("--no-browser", "Print the Stripe Checkout URL instead of opening it")
|
|
116
|
+
.option("--json", "Emit the Checkout URL as JSON")
|
|
117
|
+
.action(async function (opts) {
|
|
118
|
+
try {
|
|
119
|
+
await upgradeToTeam({
|
|
120
|
+
company: companyOf(this),
|
|
121
|
+
noBrowser: opts.browser === false,
|
|
122
|
+
json: opts.json,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
fail(err);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
110
129
|
}
|
|
111
130
|
//# sourceMappingURL=billing.js.map
|
|
@@ -439,6 +439,10 @@ function bashSingleQuote(value) {
|
|
|
439
439
|
function systemdQuoted(value) {
|
|
440
440
|
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
441
441
|
}
|
|
442
|
+
// U60 records this compatible hq-cloud artifact. Outpost launchers must stay
|
|
443
|
+
// reproducible: `@latest` could silently opt a long-lived box into a different
|
|
444
|
+
// sync protocol before its server-side eligibility is evaluated.
|
|
445
|
+
const OUTPOST_HQ_CLOUD_VERSION = "6.15.1";
|
|
442
446
|
function renderSelfDeploySyncScript(hqRoot) {
|
|
443
447
|
return `#!/usr/bin/env bash
|
|
444
448
|
set -u
|
|
@@ -448,7 +452,7 @@ HQ_ROOT=${bashSingleQuote(hqRoot)}
|
|
|
448
452
|
cd "$HQ_ROOT"
|
|
449
453
|
while true; do
|
|
450
454
|
hq auth refresh || echo "[outpost-sync] auth refresh failed, continuing"
|
|
451
|
-
npx -y --package=@indigoai-us/hq-cloud
|
|
455
|
+
npx -y --package=@indigoai-us/hq-cloud@${OUTPOST_HQ_CLOUD_VERSION} hq-sync-runner \\
|
|
452
456
|
--companies \\
|
|
453
457
|
--direction both \\
|
|
454
458
|
--on-conflict keep \\
|
|
@@ -36,7 +36,7 @@ export class ControlPlaneDbClient {
|
|
|
36
36
|
catch {
|
|
37
37
|
/* keep */
|
|
38
38
|
}
|
|
39
|
-
const planGate = planGateErrorFromPayload(res.status, payload);
|
|
39
|
+
const planGate = planGateErrorFromPayload(res.status, payload, input.companyUid);
|
|
40
40
|
if (planGate)
|
|
41
41
|
throw planGate;
|
|
42
42
|
const err = new Error(msg);
|
|
@@ -63,6 +63,22 @@ export declare class QmdExitError extends Error {
|
|
|
63
63
|
export declare class QmdCollectionMissingError extends QmdExitError {
|
|
64
64
|
name: string;
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* qmd's `collection add` exited non-zero because the requested name is ALREADY
|
|
68
|
+
* registered in qmd's YAML config under exactly that name — the idempotent,
|
|
69
|
+
* already-satisfied duplicate (qmd 2.5.3 prints `Collection '<name>' already
|
|
70
|
+
* exists.`). `collectionName` carries the parsed name so a caller can confirm
|
|
71
|
+
* the duplicate is the collection it was adding. Distinct from qmd's DIFFERENT
|
|
72
|
+
* path+pattern duplicate ("A collection already exists for this path and
|
|
73
|
+
* pattern:"), which names ANOTHER collection, carries no quoted-name form, and
|
|
74
|
+
* stays a plain {@link QmdExitError} so it keeps failing loud. HQ-CLI-Q
|
|
75
|
+
* (Sentry 7670823899).
|
|
76
|
+
*/
|
|
77
|
+
export declare class QmdCollectionExistsError extends QmdExitError {
|
|
78
|
+
readonly collectionName: string;
|
|
79
|
+
name: string;
|
|
80
|
+
constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, collectionName: string);
|
|
81
|
+
}
|
|
66
82
|
export type ResolveQmdBinOptions = {
|
|
67
83
|
env?: Record<string, string | undefined>;
|
|
68
84
|
isExecutable?: (candidate: string) => boolean;
|
|
@@ -241,7 +257,21 @@ export type ReconcileCollectionsOptions = {
|
|
|
241
257
|
bin?: string;
|
|
242
258
|
runner?: QmdProcessRunner;
|
|
243
259
|
};
|
|
244
|
-
/**
|
|
260
|
+
/**
|
|
261
|
+
* Register expected collections that qmd does not yet know about, returning the
|
|
262
|
+
* collections this call actually created (a `collection add` that exited 0).
|
|
263
|
+
*
|
|
264
|
+
* A same-name idempotent duplicate — qmd already owns the exact requested name
|
|
265
|
+
* in its YAML config (`Collection '<name>' already exists.`), while the
|
|
266
|
+
* SQLite-backed `collection list` we derive `missing` from had diverged — is
|
|
267
|
+
* NOT a failure. Reconciliation still issues the `context add`, whose qmd-side
|
|
268
|
+
* resyncConfig re-syncs YAML into SQLite and heals the divergence that provoked
|
|
269
|
+
* the retry, then continues with later collections; such a pre-existing
|
|
270
|
+
* collection is excluded from the returned list. Every other qmd failure still
|
|
271
|
+
* propagates: the DIFFERENT path+pattern duplicate, a same-name duplicate that
|
|
272
|
+
* named a different collection, a missing collection, a `context add` failure,
|
|
273
|
+
* a missing/unbuilt qmd binary. HQ-CLI-Q (Sentry 7670823899).
|
|
274
|
+
*/
|
|
245
275
|
export declare function reconcileCollections(hqRoot: string, options?: ReconcileCollectionsOptions): SearchCollection[];
|
|
246
276
|
export declare function listRegisteredCollections(hqRoot: string, options?: RunQmdOptions): Set<string>;
|
|
247
277
|
export {};
|
|
@@ -42,6 +42,25 @@ export class QmdExitError extends Error {
|
|
|
42
42
|
export class QmdCollectionMissingError extends QmdExitError {
|
|
43
43
|
name = 'QmdCollectionMissingError';
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* qmd's `collection add` exited non-zero because the requested name is ALREADY
|
|
47
|
+
* registered in qmd's YAML config under exactly that name — the idempotent,
|
|
48
|
+
* already-satisfied duplicate (qmd 2.5.3 prints `Collection '<name>' already
|
|
49
|
+
* exists.`). `collectionName` carries the parsed name so a caller can confirm
|
|
50
|
+
* the duplicate is the collection it was adding. Distinct from qmd's DIFFERENT
|
|
51
|
+
* path+pattern duplicate ("A collection already exists for this path and
|
|
52
|
+
* pattern:"), which names ANOTHER collection, carries no quoted-name form, and
|
|
53
|
+
* stays a plain {@link QmdExitError} so it keeps failing loud. HQ-CLI-Q
|
|
54
|
+
* (Sentry 7670823899).
|
|
55
|
+
*/
|
|
56
|
+
export class QmdCollectionExistsError extends QmdExitError {
|
|
57
|
+
collectionName;
|
|
58
|
+
name = 'QmdCollectionExistsError';
|
|
59
|
+
constructor(message, args, status, stdout, stderr, collectionName) {
|
|
60
|
+
super(message, args, status, stdout, stderr);
|
|
61
|
+
this.collectionName = collectionName;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
45
64
|
function isExecutable(candidate) {
|
|
46
65
|
try {
|
|
47
66
|
fs.accessSync(candidate, fs.constants.X_OK);
|
|
@@ -643,6 +662,18 @@ function finishRunQmd(result, bin, args) {
|
|
|
643
662
|
if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(detail)) {
|
|
644
663
|
throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
|
|
645
664
|
}
|
|
665
|
+
// qmd's same-name duplicate: `collection add` targeted a name YAML already
|
|
666
|
+
// owns (`Collection '<name>' already exists.`). Reconciliation treats this
|
|
667
|
+
// exact shape as already-satisfied (HQ-CLI-Q). The DIFFERENT path+pattern
|
|
668
|
+
// duplicate ("A collection already exists for this path and pattern:") names
|
|
669
|
+
// another collection and carries no quoted-name form, so it never matches and
|
|
670
|
+
// stays a plain QmdExitError. Colour is off under capture (qmd gates on
|
|
671
|
+
// isTTY) and any surrounding ANSI sits outside this substring, so the plain
|
|
672
|
+
// match is robust either way.
|
|
673
|
+
const alreadyExists = /Collection '([^']+)' already exists\./.exec(detail);
|
|
674
|
+
if (alreadyExists) {
|
|
675
|
+
throw new QmdCollectionExistsError(message, args, normalized.status, normalized.stdout, normalized.stderr, alreadyExists[1]);
|
|
676
|
+
}
|
|
646
677
|
throw new QmdExitError(message, args, normalized.status, normalized.stdout, normalized.stderr);
|
|
647
678
|
}
|
|
648
679
|
/** Run qmd with captured output and typed failures. */
|
|
@@ -741,16 +772,41 @@ export function deriveCollections(hqRoot) {
|
|
|
741
772
|
}
|
|
742
773
|
return collections;
|
|
743
774
|
}
|
|
744
|
-
/**
|
|
775
|
+
/**
|
|
776
|
+
* Register expected collections that qmd does not yet know about, returning the
|
|
777
|
+
* collections this call actually created (a `collection add` that exited 0).
|
|
778
|
+
*
|
|
779
|
+
* A same-name idempotent duplicate — qmd already owns the exact requested name
|
|
780
|
+
* in its YAML config (`Collection '<name>' already exists.`), while the
|
|
781
|
+
* SQLite-backed `collection list` we derive `missing` from had diverged — is
|
|
782
|
+
* NOT a failure. Reconciliation still issues the `context add`, whose qmd-side
|
|
783
|
+
* resyncConfig re-syncs YAML into SQLite and heals the divergence that provoked
|
|
784
|
+
* the retry, then continues with later collections; such a pre-existing
|
|
785
|
+
* collection is excluded from the returned list. Every other qmd failure still
|
|
786
|
+
* propagates: the DIFFERENT path+pattern duplicate, a same-name duplicate that
|
|
787
|
+
* named a different collection, a missing collection, a `context add` failure,
|
|
788
|
+
* a missing/unbuilt qmd binary. HQ-CLI-Q (Sentry 7670823899).
|
|
789
|
+
*/
|
|
745
790
|
export function reconcileCollections(hqRoot, options = {}) {
|
|
746
791
|
const runOptions = { bin: options.bin, runner: options.runner, cwd: hqRoot };
|
|
747
792
|
const registered = listRegisteredCollections(hqRoot, runOptions);
|
|
748
793
|
const missing = deriveCollections(hqRoot).filter((collection) => !registered.has(collection.name));
|
|
794
|
+
const created = [];
|
|
749
795
|
for (const collection of missing) {
|
|
750
|
-
|
|
796
|
+
try {
|
|
797
|
+
runQmd(['collection', 'add', collection.path, '--name', collection.name, '--mask', collection.mask], runOptions);
|
|
798
|
+
created.push(collection);
|
|
799
|
+
}
|
|
800
|
+
catch (error) {
|
|
801
|
+
// Only the exact same-name duplicate for THIS collection is already
|
|
802
|
+
// satisfied. Anything else — including a same-name duplicate that names a
|
|
803
|
+
// different collection — stays fatal.
|
|
804
|
+
if (!(error instanceof QmdCollectionExistsError) || error.collectionName !== collection.name)
|
|
805
|
+
throw error;
|
|
806
|
+
}
|
|
751
807
|
runQmd(['context', 'add', `qmd://${collection.name}`, collection.context], runOptions);
|
|
752
808
|
}
|
|
753
|
-
return
|
|
809
|
+
return created;
|
|
754
810
|
}
|
|
755
811
|
export function listRegisteredCollections(hqRoot, options = {}) {
|
|
756
812
|
const result = runQmd(['collection', 'list'], { ...options, cwd: options.cwd ?? hqRoot });
|
package/dist/main.d.ts
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
import "./node-preflight.js";
|
|
6
6
|
import "./node-network-compat.js";
|
|
7
7
|
import { Sentry } from "./sentry.js";
|
|
8
|
+
export type StreamErrorDependencies = {
|
|
9
|
+
stderr: Pick<typeof process.stderr, "write">;
|
|
10
|
+
exit: (code: number) => void;
|
|
11
|
+
};
|
|
12
|
+
export declare function handleStreamError(err: NodeJS.ErrnoException, deps?: StreamErrorDependencies): void;
|
|
8
13
|
export declare function runCli(): Promise<void>;
|
|
9
14
|
export type TopLevelErrorDependencies = {
|
|
10
15
|
sentry: Pick<typeof Sentry, "captureException">;
|
|
@@ -12,5 +17,5 @@ export type TopLevelErrorDependencies = {
|
|
|
12
17
|
setExitCode: (code: number) => void;
|
|
13
18
|
};
|
|
14
19
|
/** Classify a top-level failure without making the CLI process boundary opaque to tests. */
|
|
15
|
-
export declare function handleTopLevelError(err: unknown, deps?: TopLevelErrorDependencies): void
|
|
20
|
+
export declare function handleTopLevelError(err: unknown, deps?: TopLevelErrorDependencies): Promise<void>;
|
|
16
21
|
//# sourceMappingURL=main.d.ts.map
|
package/dist/main.js
CHANGED
|
@@ -70,7 +70,8 @@ import { isEpipe } from "./utils/epipe.js";
|
|
|
70
70
|
import { isInterceptedProcessExit } from "./utils/intercepted-process-exit.js";
|
|
71
71
|
import { isAuthError } from "./utils/auth-error.js";
|
|
72
72
|
import { isCompanySelectionError } from "./utils/company-selection-error.js";
|
|
73
|
-
import { formatPlanGateError, isPlanGateError, } from "./utils/plan-gate-error.js";
|
|
73
|
+
import { canOfferTeamUpgrade, formatPlanGateError, isPlanGateError, offerTeamUpgrade, } from "./utils/plan-gate-error.js";
|
|
74
|
+
import { upgradeToTeam } from "./utils/team-upgrade.js";
|
|
74
75
|
import { refreshVersionCache, staleAgainstCachedLatest, } from "./utils/version-check.js";
|
|
75
76
|
import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
76
77
|
import { autoUpdateAndReexec } from "./utils/self-update.js";
|
|
@@ -82,19 +83,47 @@ import { isPackageRootResolutionError, packageRootCaptureContext, } from "./util
|
|
|
82
83
|
import { fallbackOperatorMessage, unexpectedCliErrorMessage } from "./utils/unexpected-cli-error.js";
|
|
83
84
|
/** Hard upper bound for non-user-visible release-health finalization. */
|
|
84
85
|
const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
|
|
86
|
+
const defaultStreamErrorDependencies = {
|
|
87
|
+
stderr: process.stderr,
|
|
88
|
+
exit: (code) => process.exit(code),
|
|
89
|
+
};
|
|
90
|
+
// Handle an 'error' event on process.stdout/stderr without turning a benign
|
|
91
|
+
// machine-state condition into a Sentry fatal.
|
|
92
|
+
// - EPIPE: a downstream reader closed the pipe early (`hq … | head`,
|
|
93
|
+
// `source <(hq …)`, a parent that exited) — a clean exit 0, no capture. The
|
|
94
|
+
// SYNCHRONOUS path (a `write EPIPE` thrown straight out of console.log) is
|
|
95
|
+
// handled in the top-level catch below; both share `isEpipe` (HQ-6B).
|
|
96
|
+
// - ENOSPC / EDQUOT / EROFS: a full disk, exhausted quota, or read-only mount
|
|
97
|
+
// (e.g. stdout redirected to a file that fills up) — the user's machine,
|
|
98
|
+
// not an hq-cli defect. Print one actionable line (best-effort; the same
|
|
99
|
+
// disk may reject it) and exit 1 WITHOUT rethrowing, so it never becomes an
|
|
100
|
+
// uncaughtException that ships a fatal (HQ-CLI-R). sentry.ts's
|
|
101
|
+
// path-independent beforeSend also drops it, covering the buffered-write
|
|
102
|
+
// route that never surfaces here.
|
|
103
|
+
// - Anything else: rethrow — an unknown stream failure must stay loud.
|
|
104
|
+
// Exported in dependency-injectable form (mirroring handleTopLevelError) so the
|
|
105
|
+
// process boundary stays unit-testable through the ./main.js import seam.
|
|
106
|
+
export function handleStreamError(err, deps = defaultStreamErrorDependencies) {
|
|
91
107
|
if (isEpipe(err)) {
|
|
92
|
-
|
|
108
|
+
deps.exit(0);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const envMessage = environmentalFsErrorMessage(err);
|
|
112
|
+
if (envMessage) {
|
|
113
|
+
try {
|
|
114
|
+
deps.stderr.write(`hq: ${envMessage}\n`);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// The same full disk / read-only mount can reject this write too; there
|
|
118
|
+
// is nothing more to surface, but we still exit non-zero below.
|
|
119
|
+
}
|
|
120
|
+
deps.exit(1);
|
|
121
|
+
return;
|
|
93
122
|
}
|
|
94
123
|
throw err;
|
|
95
|
-
}
|
|
96
|
-
process.stdout.on("error",
|
|
97
|
-
process.stderr.on("error",
|
|
124
|
+
}
|
|
125
|
+
process.stdout.on("error", (err) => handleStreamError(err));
|
|
126
|
+
process.stderr.on("error", (err) => handleStreamError(err));
|
|
98
127
|
initSentry();
|
|
99
128
|
const program = new Command();
|
|
100
129
|
program
|
|
@@ -292,7 +321,7 @@ export async function runCli() {
|
|
|
292
321
|
await program.parseAsync();
|
|
293
322
|
}
|
|
294
323
|
catch (err) {
|
|
295
|
-
handleTopLevelError(err);
|
|
324
|
+
await handleTopLevelError(err);
|
|
296
325
|
}
|
|
297
326
|
finally {
|
|
298
327
|
// Plan-limit nag (US-016): stderr-only, never throws, never touches
|
|
@@ -319,7 +348,7 @@ const defaultTopLevelErrorDependencies = {
|
|
|
319
348
|
},
|
|
320
349
|
};
|
|
321
350
|
/** Classify a top-level failure without making the CLI process boundary opaque to tests. */
|
|
322
|
-
export function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
|
|
351
|
+
export async function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies) {
|
|
323
352
|
// A broken pipe (EPIPE) means the reader of `hq`'s output closed it early
|
|
324
353
|
// (`hq … | head`, `source <(hq …)`, a parent that exited). That is normal
|
|
325
354
|
// Unix behavior with no user-facing degradation — exit cleanly (0) and
|
|
@@ -366,6 +395,17 @@ export function handleTopLevelError(err, deps = defaultTopLevelErrorDependencies
|
|
|
366
395
|
// envelope; do not expose its raw body or retry a rejected creation.
|
|
367
396
|
deps.stderr.write(`hq: ${formatPlanGateError(err)}\n`);
|
|
368
397
|
deps.setExitCode(1);
|
|
398
|
+
if (canOfferTeamUpgrade()) {
|
|
399
|
+
try {
|
|
400
|
+
await offerTeamUpgrade(async () => {
|
|
401
|
+
await upgradeToTeam({ companyUid: err.details.companyUid });
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
catch (upgradeErr) {
|
|
405
|
+
const message = upgradeErr instanceof Error ? upgradeErr.message : String(upgradeErr);
|
|
406
|
+
deps.stderr.write(`hq: ${message}\n`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
369
409
|
}
|
|
370
410
|
else if (isExpectedUserError(err)) {
|
|
371
411
|
// HQ-CLI-6: a user-facing, client-caused error (a non-owner running
|
package/dist/sentry.js
CHANGED
|
@@ -5,6 +5,7 @@ import { beforeBreadcrumb } from "./utils/breadcrumb-buffer.js";
|
|
|
5
5
|
import { CLI_VERSION } from "./cli-version.js";
|
|
6
6
|
import { getCachedSentryUser } from "./utils/sentry-identity.js";
|
|
7
7
|
import { isEpipe } from "./utils/epipe.js";
|
|
8
|
+
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
8
9
|
/**
|
|
9
10
|
* Drop broken-pipe (EPIPE) crashes before scrubbing/send. A closed downstream
|
|
10
11
|
* reader (`hq … | head`, `source <(hq …)`, a parent that exited) is normal
|
|
@@ -18,6 +19,17 @@ import { isEpipe } from "./utils/epipe.js";
|
|
|
18
19
|
export function epipeAwareBeforeSend(event, hint) {
|
|
19
20
|
if (isEpipe(hint?.originalException))
|
|
20
21
|
return null;
|
|
22
|
+
// Path-independent belt for environmental filesystem failures (ENOSPC /
|
|
23
|
+
// EDQUOT / EROFS — a full disk, exhausted quota, or read-only mount, e.g.
|
|
24
|
+
// stdout redirected to a file that fills up). These are the user's machine,
|
|
25
|
+
// never an hq-cli defect, and can surface by several routes: a synchronous
|
|
26
|
+
// throw through the top-level boundary, a rethrow from the stdout/stderr
|
|
27
|
+
// 'error' listener, or a buffered-write uncaughtException that never reaches
|
|
28
|
+
// either. handleTopLevelError already prints an actionable message for the
|
|
29
|
+
// boundary route; dropping the event here suppresses the fatal regardless of
|
|
30
|
+
// route, while the CLI still exits non-zero. HQ-CLI-R (Sentry 7671416365).
|
|
31
|
+
if (environmentalFsErrorMessage(hint?.originalException))
|
|
32
|
+
return null;
|
|
21
33
|
return beforeSend(event, hint);
|
|
22
34
|
}
|
|
23
35
|
export function initSentry() {
|
|
@@ -9,6 +9,8 @@ export interface PlanGateDetails {
|
|
|
9
9
|
used?: number;
|
|
10
10
|
limit?: number;
|
|
11
11
|
upgradeUrl?: string;
|
|
12
|
+
/** Best-effort target captured by the shared HTTP client for interactive checkout. */
|
|
13
|
+
companyUid?: string;
|
|
12
14
|
}
|
|
13
15
|
export declare class PlanGateError extends Error {
|
|
14
16
|
readonly code: PlanGateCode;
|
|
@@ -17,13 +19,20 @@ export declare class PlanGateError extends Error {
|
|
|
17
19
|
}
|
|
18
20
|
export declare function isPlanGateError(err: unknown): err is PlanGateError;
|
|
19
21
|
export declare function formatPlanGateError(err: PlanGateError): string;
|
|
22
|
+
export declare function canOfferTeamUpgrade(argv?: readonly string[]): boolean;
|
|
23
|
+
/** Ask once, only on a real interactive terminal. */
|
|
24
|
+
export declare function offerTeamUpgrade(onConfirm: () => Promise<void>, deps?: {
|
|
25
|
+
ask?: (question: string) => Promise<string>;
|
|
26
|
+
argv?: readonly string[];
|
|
27
|
+
interactive?: boolean;
|
|
28
|
+
}): Promise<boolean>;
|
|
20
29
|
/** Decode an already-read JSON envelope from either shared HTTP client. */
|
|
21
|
-
export declare function planGateErrorFromPayload(status: number, body: unknown): PlanGateError | null;
|
|
30
|
+
export declare function planGateErrorFromPayload(status: number, body: unknown, companyUid?: string): PlanGateError | null;
|
|
22
31
|
/**
|
|
23
32
|
* Decode only the two deliberate plan-gate envelopes. Malformed, unrelated,
|
|
24
33
|
* or non-402 responses retain their existing command-specific handling.
|
|
25
34
|
*/
|
|
26
|
-
export declare function planGateErrorFromResponse(response: Response): Promise<{
|
|
35
|
+
export declare function planGateErrorFromResponse(response: Response, companyUid?: string): Promise<{
|
|
27
36
|
error: PlanGateError | null;
|
|
28
37
|
response: Response;
|
|
29
38
|
}>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const TEAM_UPGRADE_HINT = "Run `hq billing upgrade` to move to Team.";
|
|
1
2
|
export class PlanGateError extends Error {
|
|
2
3
|
code;
|
|
3
4
|
details;
|
|
@@ -5,7 +6,7 @@ export class PlanGateError extends Error {
|
|
|
5
6
|
// Commands with their own expected-error boundary commonly print
|
|
6
7
|
// `err.message`. Keeping the friendly copy here means those boundaries
|
|
7
8
|
// retain the same plan-gate voice as main.ts without per-command handling.
|
|
8
|
-
super(formatPlanGateDetails(code, details));
|
|
9
|
+
super([formatPlanGateDetails(code, details), TEAM_UPGRADE_HINT].join("\n"));
|
|
9
10
|
this.code = code;
|
|
10
11
|
this.details = details;
|
|
11
12
|
this.name = "PlanGateError";
|
|
@@ -40,10 +41,42 @@ function formatPlanGateDetails(code, details) {
|
|
|
40
41
|
].join("\n");
|
|
41
42
|
}
|
|
42
43
|
export function formatPlanGateError(err) {
|
|
43
|
-
return
|
|
44
|
+
return [
|
|
45
|
+
formatPlanGateDetails(err.code, err.details),
|
|
46
|
+
TEAM_UPGRADE_HINT,
|
|
47
|
+
].join("\n");
|
|
48
|
+
}
|
|
49
|
+
export function canOfferTeamUpgrade(argv = process.argv) {
|
|
50
|
+
return Boolean(process.stdin.isTTY && process.stderr.isTTY) &&
|
|
51
|
+
!process.env.CI &&
|
|
52
|
+
!argv.includes("--json");
|
|
53
|
+
}
|
|
54
|
+
/** Ask once, only on a real interactive terminal. */
|
|
55
|
+
export async function offerTeamUpgrade(onConfirm, deps = {}) {
|
|
56
|
+
if (!(deps.interactive ?? canOfferTeamUpgrade(deps.argv)))
|
|
57
|
+
return false;
|
|
58
|
+
if (deps.ask) {
|
|
59
|
+
const answer = await deps.ask("Upgrade to HQ Team ($500/mo) now? [y/N] ");
|
|
60
|
+
if (!/^y(?:es)?$/i.test(answer.trim()))
|
|
61
|
+
return false;
|
|
62
|
+
await onConfirm();
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
const { createInterface } = await import("node:readline/promises");
|
|
66
|
+
const readline = createInterface({ input: process.stdin, output: process.stderr });
|
|
67
|
+
try {
|
|
68
|
+
const answer = await readline.question("Upgrade to HQ Team ($500/mo) now? [y/N] ");
|
|
69
|
+
if (!/^y(?:es)?$/i.test(answer.trim()))
|
|
70
|
+
return false;
|
|
71
|
+
await onConfirm();
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
readline.close();
|
|
76
|
+
}
|
|
44
77
|
}
|
|
45
78
|
/** Decode an already-read JSON envelope from either shared HTTP client. */
|
|
46
|
-
export function planGateErrorFromPayload(status, body) {
|
|
79
|
+
export function planGateErrorFromPayload(status, body, companyUid) {
|
|
47
80
|
if (status !== 402 || !body || typeof body !== "object")
|
|
48
81
|
return null;
|
|
49
82
|
const payload = body;
|
|
@@ -55,13 +88,14 @@ export function planGateErrorFromPayload(status, body) {
|
|
|
55
88
|
...(typeof payload.used === "number" ? { used: payload.used } : {}),
|
|
56
89
|
...(typeof payload.limit === "number" ? { limit: payload.limit } : {}),
|
|
57
90
|
...(typeof payload.upgradeUrl === "string" ? { upgradeUrl: payload.upgradeUrl } : {}),
|
|
91
|
+
...(companyUid ? { companyUid } : {}),
|
|
58
92
|
});
|
|
59
93
|
}
|
|
60
94
|
/**
|
|
61
95
|
* Decode only the two deliberate plan-gate envelopes. Malformed, unrelated,
|
|
62
96
|
* or non-402 responses retain their existing command-specific handling.
|
|
63
97
|
*/
|
|
64
|
-
export async function planGateErrorFromResponse(response) {
|
|
98
|
+
export async function planGateErrorFromResponse(response, companyUid) {
|
|
65
99
|
// Leave every non-402 response untouched for its command-specific handler.
|
|
66
100
|
// A non-plan 402 is buffered and re-wrapped below so its useful error
|
|
67
101
|
// payload remains available to the existing command-specific handler.
|
|
@@ -82,7 +116,7 @@ export async function planGateErrorFromResponse(response) {
|
|
|
82
116
|
// Preserve the original non-JSON 402 body below for its existing handler.
|
|
83
117
|
}
|
|
84
118
|
return {
|
|
85
|
-
error: planGateErrorFromPayload(response.status, body),
|
|
119
|
+
error: planGateErrorFromPayload(response.status, body, companyUid),
|
|
86
120
|
response: new Response(buffer, {
|
|
87
121
|
status: response.status,
|
|
88
122
|
statusText: response.statusText,
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* - `version-check.ts` maintains the cached npm `latest` that the startup
|
|
24
24
|
* path reads, so the common case costs a small file read, not a fetch.
|
|
25
25
|
*
|
|
26
|
-
* Best-effort by design: registry unreachable, npm/pnpm missing, install
|
|
26
|
+
* Best-effort by design: registry unreachable, npm/pnpm/Bun missing, install
|
|
27
27
|
* failure, or `hq` not on PATH for the re-exec all degrade to running the
|
|
28
28
|
* command on the current version. Self-updating must never make a command less
|
|
29
29
|
* available than it was before.
|
|
@@ -56,9 +56,9 @@ export interface SelfUpdateOutcome {
|
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
58
|
* The manager-aware install argv for this layout — same routing as the hard
|
|
59
|
-
* gate:
|
|
60
|
-
*
|
|
61
|
-
* prefix so the copy that is actually running is
|
|
59
|
+
* gate: pnpm- and Bun-managed installs must be updated by their owning manager
|
|
60
|
+
* (npm would drop a copy their shims never read), and an npm install goes
|
|
61
|
+
* through the resolved prefix so the copy that is actually running is replaced.
|
|
62
62
|
*/
|
|
63
63
|
export declare function buildSelfUpdatePlan(install: RunningInstall): {
|
|
64
64
|
cmd: string;
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* - `version-check.ts` maintains the cached npm `latest` that the startup
|
|
24
24
|
* path reads, so the common case costs a small file read, not a fetch.
|
|
25
25
|
*
|
|
26
|
-
* Best-effort by design: registry unreachable, npm/pnpm missing, install
|
|
26
|
+
* Best-effort by design: registry unreachable, npm/pnpm/Bun missing, install
|
|
27
27
|
* failure, or `hq` not on PATH for the re-exec all degrade to running the
|
|
28
28
|
* command on the current version. Self-updating must never make a command less
|
|
29
29
|
* available than it was before.
|
|
@@ -38,7 +38,7 @@ import * as path from "node:path";
|
|
|
38
38
|
import semver from "semver";
|
|
39
39
|
import chalk from "chalk";
|
|
40
40
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
41
|
-
import { buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
|
|
41
|
+
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, resolveRunningInstall, runUpdateCommand, } from "./version-gate.js";
|
|
42
42
|
/**
|
|
43
43
|
* Set on the re-exec'd child so it can never self-update (and re-exec) again.
|
|
44
44
|
* One update + one re-exec per user invocation, ever.
|
|
@@ -68,13 +68,15 @@ async function fetchLatestVersion() {
|
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
70
|
* The manager-aware install argv for this layout — same routing as the hard
|
|
71
|
-
* gate:
|
|
72
|
-
*
|
|
73
|
-
* prefix so the copy that is actually running is
|
|
71
|
+
* gate: pnpm- and Bun-managed installs must be updated by their owning manager
|
|
72
|
+
* (npm would drop a copy their shims never read), and an npm install goes
|
|
73
|
+
* through the resolved prefix so the copy that is actually running is replaced.
|
|
74
74
|
*/
|
|
75
75
|
export function buildSelfUpdatePlan(install) {
|
|
76
76
|
if (install.manager === "pnpm")
|
|
77
77
|
return { cmd: "pnpm", args: buildPnpmInstallArgv() };
|
|
78
|
+
if (install.manager === "bun")
|
|
79
|
+
return { cmd: "bun", args: buildBunInstallArgv() };
|
|
78
80
|
if (install.prefix)
|
|
79
81
|
return { cmd: "npm", args: buildPrefixedInstallArgv(install.prefix) };
|
|
80
82
|
return { cmd: "npm", args: ["install", "-g", `${CLI_NAME}@latest`] };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface TeamUpgradeOptions {
|
|
2
|
+
company?: string;
|
|
3
|
+
companyUid?: string;
|
|
4
|
+
noBrowser?: boolean;
|
|
5
|
+
/** Testable override for environments where browser availability is known. */
|
|
6
|
+
headless?: boolean;
|
|
7
|
+
json?: boolean;
|
|
8
|
+
openUrl?: (url: string) => Promise<unknown>;
|
|
9
|
+
write?: (text: string) => void;
|
|
10
|
+
error?: (text: string) => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Start the owner-gated HQ Team Checkout flow. Stripe collects payment details
|
|
14
|
+
* in its hosted browser page; the CLI never receives card data.
|
|
15
|
+
*/
|
|
16
|
+
export declare function upgradeToTeam(opts?: TeamUpgradeOptions): Promise<string>;
|
|
17
|
+
//# sourceMappingURL=team-upgrade.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import open from "open";
|
|
2
|
+
import { ensureCognitoToken } from "./cognito-session.js";
|
|
3
|
+
import { getCompanyUid, vaultApiFetch } from "./vault-api.js";
|
|
4
|
+
const CHECKOUT_SUCCESS_URL = "https://hq.computer/billing?checkout=success";
|
|
5
|
+
const CHECKOUT_CANCEL_URL = "https://hq.computer/billing?checkout=cancel";
|
|
6
|
+
function isHeadless() {
|
|
7
|
+
if (process.env.CI)
|
|
8
|
+
return true;
|
|
9
|
+
if (process.platform !== "linux")
|
|
10
|
+
return false;
|
|
11
|
+
return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
|
|
12
|
+
}
|
|
13
|
+
function ownerUpgradeError() {
|
|
14
|
+
return Object.assign(new Error("Only a company owner can upgrade. Ask a company owner to upgrade."), { expected: true });
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Start the owner-gated HQ Team Checkout flow. Stripe collects payment details
|
|
18
|
+
* in its hosted browser page; the CLI never receives card data.
|
|
19
|
+
*/
|
|
20
|
+
export async function upgradeToTeam(opts = {}) {
|
|
21
|
+
const token = await ensureCognitoToken();
|
|
22
|
+
const companyUid = opts.companyUid ?? await getCompanyUid(token, opts.company);
|
|
23
|
+
const response = await vaultApiFetch({
|
|
24
|
+
token,
|
|
25
|
+
path: "/v1/billing/checkout/team",
|
|
26
|
+
method: "POST",
|
|
27
|
+
body: {
|
|
28
|
+
companyUid,
|
|
29
|
+
successUrl: CHECKOUT_SUCCESS_URL,
|
|
30
|
+
cancelUrl: CHECKOUT_CANCEL_URL,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
const body = (await response.json().catch(() => ({})));
|
|
35
|
+
if (response.status === 403 || /owner/i.test(`${body.error ?? ""} ${body.message ?? ""}`)) {
|
|
36
|
+
throw ownerUpgradeError();
|
|
37
|
+
}
|
|
38
|
+
throw Object.assign(new Error(`Could not start HQ Team checkout: ${body.error ?? body.message ?? response.statusText}`), { expected: true });
|
|
39
|
+
}
|
|
40
|
+
const { url } = (await response.json());
|
|
41
|
+
if (typeof url !== "string" || !url) {
|
|
42
|
+
throw new Error("Checkout response did not include a hosted checkout URL.");
|
|
43
|
+
}
|
|
44
|
+
const write = opts.write ?? ((text) => process.stdout.write(text));
|
|
45
|
+
if (opts.json) {
|
|
46
|
+
write(`${JSON.stringify({ url })}\n`);
|
|
47
|
+
return url;
|
|
48
|
+
}
|
|
49
|
+
write("Opening HQ Team checkout ($500/mo) in your browser — finish there and you're upgraded.\n");
|
|
50
|
+
const skipBrowser = opts.noBrowser || (opts.headless ?? isHeadless());
|
|
51
|
+
if (skipBrowser) {
|
|
52
|
+
write(`Open this URL to continue: ${url}\n`);
|
|
53
|
+
return url;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
await (opts.openUrl ?? open)(url);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
(opts.error ?? ((text) => process.stderr.write(text)))(`Couldn't launch your browser (${err instanceof Error ? err.message : String(err)}). Open this URL to continue: ${url}\n`);
|
|
60
|
+
}
|
|
61
|
+
return url;
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=team-upgrade.js.map
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -187,7 +187,13 @@ export async function vaultApiFetch(opts) {
|
|
|
187
187
|
// A plan gate is a normal, user-actionable denial. Decode it at the one
|
|
188
188
|
// shared HTTP seam so every resource-creation command reaches main.ts's
|
|
189
189
|
// friendly renderer without duplicating response parsing or retrying.
|
|
190
|
-
|
|
190
|
+
// The failed request usually already carries the target company in its
|
|
191
|
+
// body or path. Preserve it on the typed gate so an interactive checkout
|
|
192
|
+
// upgrades the same company without asking a multi-company caller again.
|
|
193
|
+
const companyUid = typeof opts.body?.companyUid === "string"
|
|
194
|
+
? opts.body.companyUid
|
|
195
|
+
: /\/(cmp_[^/?]+)/.exec(opts.path)?.[1];
|
|
196
|
+
const planGate = await planGateErrorFromResponse(response, companyUid);
|
|
191
197
|
if (planGate.error)
|
|
192
198
|
throw planGate.error;
|
|
193
199
|
return planGate.response;
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { buildSpawnPlan, quoteForWindowsShell } from "./windows-spawn.js";
|
|
31
31
|
/** Which package manager owns the running global install. */
|
|
32
|
-
export type InstallManager = "npm" | "pnpm";
|
|
32
|
+
export type InstallManager = "npm" | "pnpm" | "bun";
|
|
33
33
|
export interface VersionCheckResponse {
|
|
34
34
|
clientId: string;
|
|
35
35
|
currentVersion: string;
|
|
@@ -67,13 +67,15 @@ export declare function npmPrefixFromPackageDir(pkgDir: string): string | null;
|
|
|
67
67
|
* Those layouts fall through to the ordinary npm-prefix/manual handling.
|
|
68
68
|
*/
|
|
69
69
|
export declare function isPnpmManagedPackageDir(pkgDir: string): boolean;
|
|
70
|
+
/** Whether the running package lives inside Bun's global install tree. */
|
|
71
|
+
export declare function isBunManagedPackageDir(pkgDir: string): boolean;
|
|
70
72
|
/**
|
|
71
73
|
* Where the running CLI is installed and who owns it. Resolved in ONE pass so
|
|
72
74
|
* the package-root walk (which reads and parses a `package.json` per directory
|
|
73
75
|
* level) happens once per invocation, and so the manager and the prefix can
|
|
74
76
|
* never disagree because the filesystem shifted between two separate walks.
|
|
75
77
|
*
|
|
76
|
-
* `prefix` is `null` for
|
|
78
|
+
* `prefix` is `null` for pnpm- and Bun-managed installs even though a prefix-shaped
|
|
77
79
|
* string *can* be derived from those paths: `npmPrefixFromPackageDir` would
|
|
78
80
|
* happily hand back the pnpm store directory, and `npm install -g --prefix
|
|
79
81
|
* <store>` then unpacks a fresh copy into `<store>/lib/node_modules`, which
|
|
@@ -101,6 +103,8 @@ export declare function buildPrefixedInstallArgv(prefix: string): string[];
|
|
|
101
103
|
* the new version — which is the whole point of routing here instead of npm.
|
|
102
104
|
*/
|
|
103
105
|
export declare function buildPnpmInstallArgv(): string[];
|
|
106
|
+
/** Argv for updating a Bun-managed global install. */
|
|
107
|
+
export declare function buildBunInstallArgv(): string[];
|
|
104
108
|
/**
|
|
105
109
|
* Filesystem surface used by {@link cleanStalePartialInstall}. Injected so the
|
|
106
110
|
* cleanup logic is unit-testable without touching a real global prefix.
|
|
@@ -209,11 +213,13 @@ export declare const __test__: {
|
|
|
209
213
|
CLIENT_ID: string;
|
|
210
214
|
ENDPOINT_PATH: string;
|
|
211
215
|
FETCH_TIMEOUT_MS: number;
|
|
216
|
+
buildBunInstallArgv: typeof buildBunInstallArgv;
|
|
212
217
|
buildPnpmInstallArgv: typeof buildPnpmInstallArgv;
|
|
213
218
|
buildPrefixedInstallArgv: typeof buildPrefixedInstallArgv;
|
|
214
219
|
buildSpawnPlan: typeof buildSpawnPlan;
|
|
215
220
|
cleanStalePartialInstall: typeof cleanStalePartialInstall;
|
|
216
221
|
enforceUpdateRequired: typeof enforceUpdateRequired;
|
|
222
|
+
isBunManagedPackageDir: typeof isBunManagedPackageDir;
|
|
217
223
|
isPnpmManagedPackageDir: typeof isPnpmManagedPackageDir;
|
|
218
224
|
npmPrefixFromPackageDir: typeof npmPrefixFromPackageDir;
|
|
219
225
|
nudgeUpdateRecommended: typeof nudgeUpdateRecommended;
|
|
@@ -125,6 +125,19 @@ export function isPnpmManagedPackageDir(pkgDir) {
|
|
|
125
125
|
}
|
|
126
126
|
return false;
|
|
127
127
|
}
|
|
128
|
+
/** Whether the running package lives inside Bun's global install tree. */
|
|
129
|
+
export function isBunManagedPackageDir(pkgDir) {
|
|
130
|
+
const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
131
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
132
|
+
for (let i = 0; i < segments.length - 2; i += 1) {
|
|
133
|
+
if (segments[i] === "install" &&
|
|
134
|
+
segments[i + 1] === "global" &&
|
|
135
|
+
segments[i + 2] === "node_modules") {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
128
141
|
export function resolveRunningInstall() {
|
|
129
142
|
try {
|
|
130
143
|
const packageRoot = findRunningPackageRoot();
|
|
@@ -133,6 +146,9 @@ export function resolveRunningInstall() {
|
|
|
133
146
|
if (isPnpmManagedPackageDir(packageRoot)) {
|
|
134
147
|
return { manager: "pnpm", prefix: null, packageRoot };
|
|
135
148
|
}
|
|
149
|
+
if (isBunManagedPackageDir(packageRoot)) {
|
|
150
|
+
return { manager: "bun", prefix: null, packageRoot };
|
|
151
|
+
}
|
|
136
152
|
return {
|
|
137
153
|
manager: "npm",
|
|
138
154
|
prefix: npmPrefixFromPackageDir(packageRoot),
|
|
@@ -162,6 +178,10 @@ export function buildPrefixedInstallArgv(prefix) {
|
|
|
162
178
|
export function buildPnpmInstallArgv() {
|
|
163
179
|
return ["add", "-g", LATEST_PACKAGE_SPEC];
|
|
164
180
|
}
|
|
181
|
+
/** Argv for updating a Bun-managed global install. */
|
|
182
|
+
export function buildBunInstallArgv() {
|
|
183
|
+
return ["add", "-g", LATEST_PACKAGE_SPEC];
|
|
184
|
+
}
|
|
165
185
|
const nodeStaleInstallFs = {
|
|
166
186
|
readdirSync: (dir) => readdirSync(dir),
|
|
167
187
|
existsSync,
|
|
@@ -338,6 +358,8 @@ function performUpdate(command, runner = runUpdateCommand) {
|
|
|
338
358
|
function manualUpdateCommand(install, decision) {
|
|
339
359
|
if (install.manager === "pnpm")
|
|
340
360
|
return `pnpm ${buildPnpmInstallArgv().join(" ")}`;
|
|
361
|
+
if (install.manager === "bun")
|
|
362
|
+
return `bun ${buildBunInstallArgv().join(" ")}`;
|
|
341
363
|
if (install.prefix)
|
|
342
364
|
return `npm ${buildPrefixedInstallArgv(install.prefix).join(" ")}`;
|
|
343
365
|
return decision.updateCommand;
|
|
@@ -360,7 +382,9 @@ function nudgeUpdateRecommended(decision, install = resolveRunningInstall()) {
|
|
|
360
382
|
console.error(msg);
|
|
361
383
|
const command = install.manager === "pnpm"
|
|
362
384
|
? `pnpm ${buildPnpmInstallArgv().join(" ")}`
|
|
363
|
-
:
|
|
385
|
+
: install.manager === "bun"
|
|
386
|
+
? `bun ${buildBunInstallArgv().join(" ")}`
|
|
387
|
+
: decision.updateCommand;
|
|
364
388
|
if (command) {
|
|
365
389
|
console.error(chalk.dim(` Update: ${command}`));
|
|
366
390
|
}
|
|
@@ -382,13 +406,14 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
382
406
|
console.error(chalk.dim(` ${decision.message}`));
|
|
383
407
|
const command = decision.updateCommand;
|
|
384
408
|
// ONE package-root walk decides both the manager and the prefix. A
|
|
385
|
-
// pnpm-managed install must be updated by
|
|
386
|
-
// and any npm prefix are
|
|
409
|
+
// A pnpm- or Bun-managed install must be updated by its owning manager; the
|
|
410
|
+
// server's `updateCommand` and any npm prefix are wrong for those layouts, so
|
|
411
|
+
// `resolveRunningInstall`
|
|
387
412
|
// already reports `prefix: null` there and neither is consulted below.
|
|
388
413
|
const install = (deps.resolveInstall ?? resolveRunningInstall)();
|
|
389
|
-
const
|
|
414
|
+
const isManagedOutsideNpm = install.manager !== "npm";
|
|
390
415
|
const prefix = install.prefix;
|
|
391
|
-
if (!
|
|
416
|
+
if (!isManagedOutsideNpm && !command && !prefix) {
|
|
392
417
|
console.error(chalk.red(" No updateCommand provided by hq-pro — see https://hq.indigo.ai/docs/cli-update for manual steps."));
|
|
393
418
|
if (decision.downloadUrl) {
|
|
394
419
|
console.error(chalk.dim(` Download: ${decision.downloadUrl}`));
|
|
@@ -400,10 +425,14 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
400
425
|
// the EXACT same command under elevation.
|
|
401
426
|
let primaryCmd;
|
|
402
427
|
let primaryArgs;
|
|
403
|
-
if (
|
|
428
|
+
if (install.manager === "pnpm") {
|
|
404
429
|
primaryCmd = "pnpm";
|
|
405
430
|
primaryArgs = buildPnpmInstallArgv();
|
|
406
431
|
}
|
|
432
|
+
else if (install.manager === "bun") {
|
|
433
|
+
primaryCmd = "bun";
|
|
434
|
+
primaryArgs = buildBunInstallArgv();
|
|
435
|
+
}
|
|
407
436
|
else if (prefix) {
|
|
408
437
|
primaryCmd = "npm";
|
|
409
438
|
primaryArgs = buildPrefixedInstallArgv(prefix);
|
|
@@ -414,10 +443,10 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
414
443
|
primaryArgs = parts.slice(1);
|
|
415
444
|
}
|
|
416
445
|
let result;
|
|
417
|
-
if (
|
|
418
|
-
console.error(chalk.dim(
|
|
419
|
-
console.error(chalk.dim(` Running:
|
|
420
|
-
result = performUpdateCommand(
|
|
446
|
+
if (isManagedOutsideNpm) {
|
|
447
|
+
console.error(chalk.dim(` Detected a ${install.manager}-managed global install; updating with ${install.manager}`));
|
|
448
|
+
console.error(chalk.dim(` Running: ${primaryCmd} ${primaryArgs.join(" ")}`));
|
|
449
|
+
result = performUpdateCommand(primaryCmd, primaryArgs, runner);
|
|
421
450
|
}
|
|
422
451
|
else if (prefix) {
|
|
423
452
|
console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
|
|
@@ -455,10 +484,10 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
455
484
|
// through to the manual path unchanged, while headless boxes with passwordless
|
|
456
485
|
// sudo self-update cleanly.
|
|
457
486
|
//
|
|
458
|
-
// Never for pnpm
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
if (!result.ok && primaryCmd && !
|
|
487
|
+
// Never for pnpm or Bun: elevation changes the package manager's global home,
|
|
488
|
+
// leaving the user's shim untouched while reporting success. A failed
|
|
489
|
+
// non-npm update must surface instead.
|
|
490
|
+
if (!result.ok && primaryCmd && !isManagedOutsideNpm) {
|
|
462
491
|
console.error(chalk.dim(` Update failed unprivileged; retrying with: sudo -n ${primaryCmd} ${primaryArgs.join(" ")}`));
|
|
463
492
|
const sudoResult = performUpdateCommand("sudo", ["-n", primaryCmd, ...primaryArgs], runner);
|
|
464
493
|
if (sudoResult.ok)
|
|
@@ -476,9 +505,9 @@ function enforceUpdateRequired(decision, deps = {}) {
|
|
|
476
505
|
const manual = manualUpdateCommand(install, decision) ?? command;
|
|
477
506
|
console.error(chalk.dim(` Try manually: ${manual}`));
|
|
478
507
|
// Only informational, and only when we could not run the right manager at
|
|
479
|
-
// all: hq-pro's command is npm-shaped, so following it on a pnpm
|
|
480
|
-
//
|
|
481
|
-
if (
|
|
508
|
+
// all: hq-pro's command is npm-shaped, so following it on a pnpm or Bun
|
|
509
|
+
// layout would update a different copy and leave the running shim stale.
|
|
510
|
+
if (isManagedOutsideNpm && result.code === "ENOENT" && command) {
|
|
482
511
|
console.error(chalk.dim(` (hq-pro suggests \`${command}\` — that is for npm-managed installs; use it only if you have switched this install to npm.)`));
|
|
483
512
|
}
|
|
484
513
|
process.exit(75);
|
|
@@ -524,11 +553,13 @@ export const __test__ = {
|
|
|
524
553
|
CLIENT_ID,
|
|
525
554
|
ENDPOINT_PATH,
|
|
526
555
|
FETCH_TIMEOUT_MS,
|
|
556
|
+
buildBunInstallArgv,
|
|
527
557
|
buildPnpmInstallArgv,
|
|
528
558
|
buildPrefixedInstallArgv,
|
|
529
559
|
buildSpawnPlan,
|
|
530
560
|
cleanStalePartialInstall,
|
|
531
561
|
enforceUpdateRequired,
|
|
562
|
+
isBunManagedPackageDir,
|
|
532
563
|
isPnpmManagedPackageDir,
|
|
533
564
|
npmPrefixFromPackageDir,
|
|
534
565
|
nudgeUpdateRecommended,
|