@indigoai-us/hq-cli 5.50.1 → 5.51.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/dist/bin/hq-auth-refresh.d.ts +1 -1
- package/dist/bin/hq-auth-refresh.js +5 -2
- package/dist/commands/members.d.ts +17 -0
- package/dist/commands/members.js +65 -28
- package/dist/commands/onboard-warning.d.ts +7 -0
- package/dist/commands/onboard-warning.js +14 -0
- package/dist/commands/onboard.js +5 -5
- package/dist/commands/pack-install.d.ts +12 -0
- package/dist/commands/pack-install.js +74 -3
- package/dist/commands/packs.js +17 -3
- package/dist/commands/people.d.ts +26 -1
- package/dist/commands/people.js +70 -7
- package/dist/commands/secrets-scope.d.ts +20 -0
- package/dist/commands/secrets-scope.js +19 -0
- package/dist/commands/secrets.js +21 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +44 -14
- package/dist/node-preflight.d.ts +39 -0
- package/dist/node-preflight.js +55 -0
- package/dist/sentry.d.ts +12 -0
- package/dist/sentry.js +19 -3
- package/dist/types.d.ts +18 -0
- package/dist/utils/epipe.d.ts +8 -0
- package/dist/utils/epipe.js +30 -0
- package/dist/utils/intercepted-process-exit.d.ts +7 -0
- package/dist/utils/intercepted-process-exit.js +38 -0
- package/dist/utils/pack-contributions.d.ts +7 -0
- package/dist/utils/pack-contributions.js +12 -2
- package/dist/utils/version-gate.d.ts +40 -1
- package/dist/utils/version-gate.js +91 -20
- package/e2e/cli.test.ts +35 -0
- package/package.json +1 -1
- package/src/bin/hq-auth-refresh.ts +3 -0
- package/src/commands/members.test.ts +176 -0
- package/src/commands/members.ts +113 -28
- package/src/commands/onboard-warning.test.ts +26 -0
- package/src/commands/onboard-warning.ts +12 -0
- package/src/commands/onboard.ts +4 -7
- package/src/commands/pack-install.test.ts +144 -0
- package/src/commands/pack-install.ts +86 -1
- package/src/commands/packs.ts +19 -0
- package/src/commands/people.test.ts +212 -5
- package/src/commands/people.ts +141 -5
- package/src/commands/secrets-scope.test.ts +56 -0
- package/src/commands/secrets-scope.ts +32 -0
- package/src/commands/secrets.ts +24 -10
- package/src/index.ts +40 -12
- package/src/node-preflight.test.ts +60 -0
- package/src/node-preflight.ts +67 -0
- package/src/sentry-epipe.test.ts +37 -0
- package/src/sentry-release.test.ts +54 -0
- package/src/sentry.ts +21 -1
- package/src/types.ts +19 -1
- package/src/utils/epipe.test.ts +28 -0
- package/src/utils/epipe.ts +29 -0
- package/src/utils/intercepted-process-exit.test.ts +37 -0
- package/src/utils/intercepted-process-exit.ts +36 -0
- package/src/utils/pack-contributions.test.ts +53 -0
- package/src/utils/pack-contributions.ts +17 -0
- package/src/utils/version-gate.test.ts +122 -0
- package/src/utils/version-gate.ts +109 -13
package/src/sentry.ts
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import * as Sentry from "@sentry/node";
|
|
2
|
+
import type { ErrorEvent, EventHint } from "@sentry/node";
|
|
2
3
|
import { BUNDLED_DSN } from "./sentry-dsn.generated.js";
|
|
3
4
|
import { beforeSend } from "./sentry-before-send.js";
|
|
4
5
|
import { beforeBreadcrumb } from "./utils/breadcrumb-buffer.js";
|
|
5
6
|
import { CLI_VERSION } from "./cli-version.js";
|
|
6
7
|
import { getCachedSentryUser } from "./utils/sentry-identity.js";
|
|
8
|
+
import { isEpipe } from "./utils/epipe.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Drop broken-pipe (EPIPE) crashes before scrubbing/send. A closed downstream
|
|
12
|
+
* reader (`hq … | head`, `source <(hq …)`, a parent that exited) is normal
|
|
13
|
+
* Unix behavior with no user-facing degradation — never a defect to report.
|
|
14
|
+
* This is the path-independent catch-all for HQ-6B: whichever way an EPIPE
|
|
15
|
+
* surfaces (a synchronous throw into the command catch, an async stream
|
|
16
|
+
* 'error', or a write outside the top-level try), it never ships a fatal. The
|
|
17
|
+
* clean-exit handlers in index.ts still keep exit code 0 for the common paths.
|
|
18
|
+
* Consistent with the swallow-EPIPE posture established in #138.
|
|
19
|
+
*/
|
|
20
|
+
export function epipeAwareBeforeSend(
|
|
21
|
+
event: ErrorEvent,
|
|
22
|
+
hint: EventHint,
|
|
23
|
+
): ErrorEvent | null {
|
|
24
|
+
if (isEpipe(hint?.originalException)) return null;
|
|
25
|
+
return beforeSend(event, hint);
|
|
26
|
+
}
|
|
7
27
|
|
|
8
28
|
export function initSentry(): void {
|
|
9
29
|
const dsn = BUNDLED_DSN || process.env.SENTRY_DSN;
|
|
@@ -18,7 +38,7 @@ export function initSentry(): void {
|
|
|
18
38
|
initialScope: {
|
|
19
39
|
tags: { repo: "hq-cli" },
|
|
20
40
|
},
|
|
21
|
-
beforeSend,
|
|
41
|
+
beforeSend: epipeAwareBeforeSend,
|
|
22
42
|
beforeBreadcrumb,
|
|
23
43
|
});
|
|
24
44
|
// Attribute events to the logged-in HQ identity (best-effort; null when not
|
package/src/types.ts
CHANGED
|
@@ -95,12 +95,30 @@ export interface PackAuthor {
|
|
|
95
95
|
displayName: string;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* A pack-to-pack dependency (M0). OPTIONAL and backwards-compatible — packs
|
|
100
|
+
* published before this field omit `requires.packs` and install unchanged. When
|
|
101
|
+
* present, each entry names another content pack that MUST already be installed
|
|
102
|
+
* before this one (enforced at install time by `assertPackDependencies`, which
|
|
103
|
+
* tracks installed packs by FILESYSTEM PRESENCE — not `modules.yaml`). `version`
|
|
104
|
+
* is an optional semver RANGE the installed dependency must satisfy.
|
|
105
|
+
*/
|
|
106
|
+
export interface PackDependency {
|
|
107
|
+
name: string; // ^hq-pack-[a-z0-9][a-z0-9-]*$
|
|
108
|
+
version?: string; // optional semver range
|
|
109
|
+
}
|
|
110
|
+
|
|
98
111
|
export interface PackManifest {
|
|
99
112
|
name: string; // ^hq-pack-[a-z0-9][a-z0-9-]*$
|
|
100
113
|
version: string; // semver
|
|
101
114
|
publisher: string; // @scope
|
|
102
115
|
access: 'public' | 'private';
|
|
103
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Host + pack prerequisites. `hqCore` is a required semver RANGE the host HQ
|
|
118
|
+
* must satisfy. `packs` (M0) is an OPTIONAL list of other content packs that
|
|
119
|
+
* must be installed first — see PackDependency.
|
|
120
|
+
*/
|
|
121
|
+
requires: { hqCore: string; packs?: PackDependency[] };
|
|
104
122
|
contributes: Partial<Record<PackContributeKey, string[]>>;
|
|
105
123
|
description?: string;
|
|
106
124
|
license?: string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isEpipe } from "./epipe.js";
|
|
3
|
+
|
|
4
|
+
describe("isEpipe", () => {
|
|
5
|
+
it("matches a real Node EPIPE error", () => {
|
|
6
|
+
const err = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
|
7
|
+
expect(isEpipe(err)).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("matches a plain object carrying code EPIPE", () => {
|
|
11
|
+
expect(isEpipe({ code: "EPIPE" })).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("does NOT match other errno codes (so real faults still report)", () => {
|
|
15
|
+
expect(isEpipe(Object.assign(new Error("disk full"), { code: "ENOSPC" }))).toBe(
|
|
16
|
+
false,
|
|
17
|
+
);
|
|
18
|
+
expect(isEpipe(Object.assign(new Error("nope"), { code: "EACCES" }))).toBe(false);
|
|
19
|
+
expect(isEpipe(new Error("plain error with no code"))).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("does NOT match non-error values", () => {
|
|
23
|
+
expect(isEpipe(null)).toBe(false);
|
|
24
|
+
expect(isEpipe(undefined)).toBe(false);
|
|
25
|
+
expect(isEpipe("EPIPE")).toBe(false);
|
|
26
|
+
expect(isEpipe(42)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// src/utils/epipe.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify the "broken pipe" (EPIPE) error: a downstream reader closed the
|
|
4
|
+
// pipe before the CLI finished writing to stdout/stderr. This is normal,
|
|
5
|
+
// expected Unix behavior — `hq … | head`, `source <(hq …)`, or a parent
|
|
6
|
+
// process that exits while `hq` is still printing — NOT an HQ code defect.
|
|
7
|
+
//
|
|
8
|
+
// HQ-6B: a synchronous `write EPIPE` thrown from `console.log` (the Sentry
|
|
9
|
+
// console-instrumentation wraps it) propagated out of an awaited command into
|
|
10
|
+
// the CLI's top-level `catch`, which captured it via `Sentry.captureException`
|
|
11
|
+
// and shipped a fatal — even though the only thing that happened is the
|
|
12
|
+
// consumer of `hq`'s output went away. The async path (an emitted stream
|
|
13
|
+
// 'error' event) was already handled by the stdout/stderr listeners in
|
|
14
|
+
// index.ts; this predicate closes the synchronous-throw path too, mirroring
|
|
15
|
+
// the environmental-error carve-out (HQ-CLI-2).
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* True when `err` is a Node EPIPE error — the pipe `hq` was writing to was
|
|
19
|
+
* closed by its reader. Callers should treat this as a clean, expected exit
|
|
20
|
+
* (code 0) and SKIP Sentry capture: there is no defect and no user-facing
|
|
21
|
+
* degradation to fix.
|
|
22
|
+
*/
|
|
23
|
+
export function isEpipe(err: unknown): boolean {
|
|
24
|
+
return (
|
|
25
|
+
typeof err === "object" &&
|
|
26
|
+
err !== null &&
|
|
27
|
+
(err as NodeJS.ErrnoException).code === "EPIPE"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isInterceptedProcessExit } from "./intercepted-process-exit.js";
|
|
3
|
+
|
|
4
|
+
describe("isInterceptedProcessExit", () => {
|
|
5
|
+
it("matches the fuzz harness's intercepted-process.exit marker error", () => {
|
|
6
|
+
expect(isInterceptedProcessExit(new Error("fuzz-intercepted-process-exit"))).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("matches when the marker is wrapped in a longer message", () => {
|
|
10
|
+
expect(
|
|
11
|
+
isInterceptedProcessExit(new Error("audit: fuzz-intercepted-process-exit code=1")),
|
|
12
|
+
).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("matches a plain object carrying the marker message", () => {
|
|
16
|
+
expect(isInterceptedProcessExit({ message: "fuzz-intercepted-process-exit" })).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("matches a bare string carrying the marker", () => {
|
|
20
|
+
expect(isInterceptedProcessExit("fuzz-intercepted-process-exit")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("does NOT match genuine errors (so real faults still report)", () => {
|
|
24
|
+
expect(isInterceptedProcessExit(new Error("boom"))).toBe(false);
|
|
25
|
+
expect(isInterceptedProcessExit(new Error("ENOENT: no such file or directory"))).toBe(false);
|
|
26
|
+
expect(isInterceptedProcessExit(Object.assign(new Error("nope"), { code: "EACCES" }))).toBe(
|
|
27
|
+
false,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("does NOT match non-error values", () => {
|
|
32
|
+
expect(isInterceptedProcessExit(null)).toBe(false);
|
|
33
|
+
expect(isInterceptedProcessExit(undefined)).toBe(false);
|
|
34
|
+
expect(isInterceptedProcessExit(42)).toBe(false);
|
|
35
|
+
expect(isInterceptedProcessExit({})).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/utils/intercepted-process-exit.ts
|
|
2
|
+
//
|
|
3
|
+
// Classify the "intercepted process.exit" synthetic error (HQ-CLI-3).
|
|
4
|
+
//
|
|
5
|
+
// Security/audit FUZZ harnesses (e.g. `/opt/audit/fuzz-exports.js`) monkey-patch
|
|
6
|
+
// `process.exit` to THROW a marker instead of actually terminating, so the
|
|
7
|
+
// fuzzer can keep exercising a binary's entrypoints. When such a harness drives
|
|
8
|
+
// `hq` (e.g. `hq <unknown-command>`), commander legitimately calls
|
|
9
|
+
// `process.exit(1)` — and the patched exit turns that normal CLI control-flow
|
|
10
|
+
// into a thrown `Error: fuzz-intercepted-process-exit` that propagates out of
|
|
11
|
+
// `program.parseAsync()` into the CLI's top-level catch, which then shipped it to
|
|
12
|
+
// Sentry as a fatal.
|
|
13
|
+
//
|
|
14
|
+
// This is a test-harness artifact, NOT an hq-cli defect: a real user's
|
|
15
|
+
// `process.exit` exits the process and nothing is ever thrown or captured.
|
|
16
|
+
// There is no user-facing degradation to fix, so the top-level catch skips
|
|
17
|
+
// Sentry capture for it — the genuine "expected, non-actionable" carve-out,
|
|
18
|
+
// mirroring the EPIPE (HQ-6B) and environmental-FS (HQ-CLI-2) carve-outs.
|
|
19
|
+
|
|
20
|
+
/** The marker a fuzz/audit harness throws in place of a real `process.exit`. */
|
|
21
|
+
const INTERCEPTED_PROCESS_EXIT_MARKER = "fuzz-intercepted-process-exit";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* True when `err` is a fuzz/audit harness's intercepted-`process.exit` marker
|
|
25
|
+
* rather than a genuine fault. Callers should SKIP Sentry capture (no defect, no
|
|
26
|
+
* user-facing degradation) while preserving the intended exit code.
|
|
27
|
+
*/
|
|
28
|
+
export function isInterceptedProcessExit(err: unknown): boolean {
|
|
29
|
+
if (typeof err === "object" && err !== null) {
|
|
30
|
+
const message = (err as { message?: unknown }).message;
|
|
31
|
+
if (typeof message === "string" && message.includes(INTERCEPTED_PROCESS_EXIT_MARKER)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return typeof err === "string" && err.includes(INTERCEPTED_PROCESS_EXIT_MARKER);
|
|
36
|
+
}
|
|
@@ -16,11 +16,13 @@ import {
|
|
|
16
16
|
contributionLinks,
|
|
17
17
|
linkStatus,
|
|
18
18
|
listInstalledPacks,
|
|
19
|
+
findDependentPacks,
|
|
19
20
|
unwirePack,
|
|
20
21
|
unwirePackMcp,
|
|
21
22
|
packagesDir,
|
|
22
23
|
readHqVersion,
|
|
23
24
|
type WiredLink,
|
|
25
|
+
type InstalledPack,
|
|
24
26
|
} from './pack-contributions.js';
|
|
25
27
|
import { parse as parseToml } from 'smol-toml';
|
|
26
28
|
import {
|
|
@@ -489,3 +491,54 @@ describe('US-009: unwirePackMcp (mcp un-registration parallel to symlink unwire)
|
|
|
489
491
|
expect(fs.readFileSync(codexConfigPath(env), 'utf-8')).toBe(original);
|
|
490
492
|
});
|
|
491
493
|
});
|
|
494
|
+
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
// findDependentPacks — uninstall dependents guard (M0)
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
describe('findDependentPacks (M0)', () => {
|
|
500
|
+
function ip(
|
|
501
|
+
name: string,
|
|
502
|
+
requiresPacks?: Array<{ name: string; version?: string }>,
|
|
503
|
+
): InstalledPack {
|
|
504
|
+
return {
|
|
505
|
+
name,
|
|
506
|
+
dir: `/tmp/nope/${name}`,
|
|
507
|
+
manifest: {
|
|
508
|
+
name,
|
|
509
|
+
version: '1.0.0',
|
|
510
|
+
publisher: '@indigoai-us',
|
|
511
|
+
access: 'public',
|
|
512
|
+
requires: { hqCore: '>=15.0.0', packs: requiresPacks },
|
|
513
|
+
contributes: { skills: ['x'] },
|
|
514
|
+
},
|
|
515
|
+
} as InstalledPack;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
it('returns the packs that require the named pack', () => {
|
|
519
|
+
const installed = [
|
|
520
|
+
ip('hq-pack-crm'),
|
|
521
|
+
ip('hq-pack-accounting', [{ name: 'hq-pack-crm', version: '>=1.0.0' }]),
|
|
522
|
+
ip('hq-pack-unrelated'),
|
|
523
|
+
];
|
|
524
|
+
const deps = findDependentPacks(installed, 'hq-pack-crm');
|
|
525
|
+
expect(deps.map((p) => p.name)).toEqual(['hq-pack-accounting']);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('returns empty when no installed pack requires it', () => {
|
|
529
|
+
const installed = [ip('hq-pack-crm'), ip('hq-pack-unrelated')];
|
|
530
|
+
expect(findDependentPacks(installed, 'hq-pack-crm')).toEqual([]);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('never counts a pack that lists itself as its own dependent', () => {
|
|
534
|
+
// Defensive: a self-referencing manifest (rejected at install) must not make
|
|
535
|
+
// a pack un-removable.
|
|
536
|
+
const installed = [ip('hq-pack-weird', [{ name: 'hq-pack-weird' }])];
|
|
537
|
+
expect(findDependentPacks(installed, 'hq-pack-weird')).toEqual([]);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('tolerates an unreadable manifest (manifest === null)', () => {
|
|
541
|
+
const broken: InstalledPack = { name: 'hq-pack-broken', dir: '/tmp/nope', manifest: null };
|
|
542
|
+
expect(findDependentPacks([broken], 'hq-pack-crm')).toEqual([]);
|
|
543
|
+
});
|
|
544
|
+
});
|
|
@@ -264,6 +264,23 @@ export function listInstalledPacks(hqRoot: string): InstalledPack[] {
|
|
|
264
264
|
return out;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Installed packs that declare `name` in their `requires.packs` (M0). Pure over
|
|
269
|
+
* the supplied list — the uninstall dependents guard calls this with
|
|
270
|
+
* `listInstalledPacks(hqRoot)`. Excludes the pack named `name` itself, so a
|
|
271
|
+
* self-reference never counts as its own dependent.
|
|
272
|
+
*/
|
|
273
|
+
export function findDependentPacks(
|
|
274
|
+
installed: InstalledPack[],
|
|
275
|
+
name: string,
|
|
276
|
+
): InstalledPack[] {
|
|
277
|
+
return installed.filter(
|
|
278
|
+
(p) =>
|
|
279
|
+
p.name !== name &&
|
|
280
|
+
(p.manifest?.requires?.packs ?? []).some((d) => d.name === name),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
267
284
|
// ---------------------------------------------------------------------------
|
|
268
285
|
// Un-wiring (the uninstall guarantee)
|
|
269
286
|
// ---------------------------------------------------------------------------
|
|
@@ -49,6 +49,51 @@ describe("shouldSkipGate", () => {
|
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
describe("prefix install helpers", () => {
|
|
53
|
+
it("derives the npm prefix from unix global package layouts", async () => {
|
|
54
|
+
const { __test__ } = await loadModule();
|
|
55
|
+
expect(
|
|
56
|
+
__test__.npmPrefixFromPackageDir(
|
|
57
|
+
"/Users/x/Library/Application Support/Indigo HQ/toolchain/npm-global/lib/node_modules/@indigoai-us/hq-cli",
|
|
58
|
+
),
|
|
59
|
+
).toBe("/Users/x/Library/Application Support/Indigo HQ/toolchain/npm-global");
|
|
60
|
+
expect(
|
|
61
|
+
__test__.npmPrefixFromPackageDir(
|
|
62
|
+
"/usr/local/lib/node_modules/@indigoai-us/hq-cli",
|
|
63
|
+
),
|
|
64
|
+
).toBe("/usr/local");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("derives the npm prefix from a windows-style package layout", async () => {
|
|
68
|
+
const { __test__ } = await loadModule();
|
|
69
|
+
expect(
|
|
70
|
+
__test__.npmPrefixFromPackageDir(
|
|
71
|
+
"C:\\Users\\x\\AppData\\Roaming\\npm\\node_modules\\@indigoai-us\\hq-cli",
|
|
72
|
+
),
|
|
73
|
+
).toBe("C:/Users/x/AppData/Roaming/npm");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns null when the package path is not under node_modules", async () => {
|
|
77
|
+
const { __test__ } = await loadModule();
|
|
78
|
+
expect(
|
|
79
|
+
__test__.npmPrefixFromPackageDir(
|
|
80
|
+
"/Users/x/dev/hq/packages/hq-cli",
|
|
81
|
+
),
|
|
82
|
+
).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("builds the prefixed npm install argv", async () => {
|
|
86
|
+
const { __test__ } = await loadModule();
|
|
87
|
+
expect(__test__.buildPrefixedInstallArgv("/tmp/npm-global")).toEqual([
|
|
88
|
+
"install",
|
|
89
|
+
"-g",
|
|
90
|
+
"--prefix",
|
|
91
|
+
"/tmp/npm-global",
|
|
92
|
+
"@indigoai-us/hq-cli@latest",
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
52
97
|
describe("enforceVersionGate — opt-out + soft paths (no process.exit)", () => {
|
|
53
98
|
it("is silent + no fetch when HQ_NO_UPDATE_CHECK=1", async () => {
|
|
54
99
|
vi.stubEnv("HQ_NO_UPDATE_CHECK", "1");
|
|
@@ -242,4 +287,81 @@ describe("enforceVersionGate — hard-update path", () => {
|
|
|
242
287
|
expect(body.currentVersion).toBe("5.10.0");
|
|
243
288
|
expect(typeof body.platform).toBe("string");
|
|
244
289
|
});
|
|
290
|
+
|
|
291
|
+
it("installs with the resolved running prefix instead of the bare server command", async () => {
|
|
292
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
293
|
+
const exitSpy = vi
|
|
294
|
+
.spyOn(process, "exit")
|
|
295
|
+
.mockImplementation(((code?: number) => {
|
|
296
|
+
throw new Error(`__process_exit__:${code ?? 0}`);
|
|
297
|
+
}) as never);
|
|
298
|
+
const runner = vi.fn().mockReturnValue({ ok: true });
|
|
299
|
+
const { __test__ } = await loadModule();
|
|
300
|
+
const prefix = "/Users/x/Library/Application Support/Indigo HQ/toolchain/npm-global";
|
|
301
|
+
|
|
302
|
+
expect(() =>
|
|
303
|
+
__test__.enforceUpdateRequired(
|
|
304
|
+
{
|
|
305
|
+
clientId: "hq-cli",
|
|
306
|
+
currentVersion: "5.10.0",
|
|
307
|
+
minVersion: "5.20.0",
|
|
308
|
+
latestVersion: "5.24.0",
|
|
309
|
+
updateRequired: true,
|
|
310
|
+
updateRecommended: false,
|
|
311
|
+
updateCommand: "npm install -g @indigoai-us/hq-cli@latest",
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
resolvePrefix: () => prefix,
|
|
315
|
+
runner,
|
|
316
|
+
},
|
|
317
|
+
),
|
|
318
|
+
).toThrow(/__process_exit__:0/);
|
|
319
|
+
|
|
320
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
321
|
+
expect(runner).toHaveBeenCalledWith("npm", [
|
|
322
|
+
"install",
|
|
323
|
+
"-g",
|
|
324
|
+
"--prefix",
|
|
325
|
+
prefix,
|
|
326
|
+
"@indigoai-us/hq-cli@latest",
|
|
327
|
+
]);
|
|
328
|
+
expect(runner).not.toHaveBeenCalledWith("npm", [
|
|
329
|
+
"install",
|
|
330
|
+
"-g",
|
|
331
|
+
"@indigoai-us/hq-cli@latest",
|
|
332
|
+
]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("falls back to the server updateCommand when no running prefix resolves", async () => {
|
|
336
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
337
|
+
const exitSpy = vi
|
|
338
|
+
.spyOn(process, "exit")
|
|
339
|
+
.mockImplementation(((code?: number) => {
|
|
340
|
+
throw new Error(`__process_exit__:${code ?? 0}`);
|
|
341
|
+
}) as never);
|
|
342
|
+
const performUpdateString = vi.fn().mockReturnValue({ ok: true });
|
|
343
|
+
const { __test__ } = await loadModule();
|
|
344
|
+
const updateCommand = "npm install -g @indigoai-us/hq-cli@latest";
|
|
345
|
+
|
|
346
|
+
expect(() =>
|
|
347
|
+
__test__.enforceUpdateRequired(
|
|
348
|
+
{
|
|
349
|
+
clientId: "hq-cli",
|
|
350
|
+
currentVersion: "5.10.0",
|
|
351
|
+
minVersion: "5.20.0",
|
|
352
|
+
latestVersion: "5.24.0",
|
|
353
|
+
updateRequired: true,
|
|
354
|
+
updateRecommended: false,
|
|
355
|
+
updateCommand,
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
performUpdateString,
|
|
359
|
+
resolvePrefix: () => null,
|
|
360
|
+
},
|
|
361
|
+
),
|
|
362
|
+
).toThrow(/__process_exit__:0/);
|
|
363
|
+
|
|
364
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
365
|
+
expect(performUpdateString).toHaveBeenCalledWith(updateCommand);
|
|
366
|
+
});
|
|
245
367
|
});
|
|
@@ -29,13 +29,17 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { spawnSync } from "node:child_process";
|
|
32
|
+
import { readFileSync } from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
32
35
|
import chalk from "chalk";
|
|
33
|
-
import { CLI_VERSION } from "../cli-version.js";
|
|
36
|
+
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
34
37
|
import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
|
|
35
38
|
|
|
36
39
|
const CLIENT_ID = "hq-cli";
|
|
37
40
|
const ENDPOINT_PATH = "/v1/client-version/check";
|
|
38
41
|
const FETCH_TIMEOUT_MS = 3_000;
|
|
42
|
+
const LATEST_PACKAGE_SPEC = `${CLI_NAME}@latest`;
|
|
39
43
|
|
|
40
44
|
interface VersionCheckResponse {
|
|
41
45
|
clientId: string;
|
|
@@ -53,6 +57,53 @@ function isOptedOut(): boolean {
|
|
|
53
57
|
return process.env.HQ_NO_UPDATE_CHECK === "1";
|
|
54
58
|
}
|
|
55
59
|
|
|
60
|
+
function findRunningPackageRoot(): string | null {
|
|
61
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
while (true) {
|
|
63
|
+
try {
|
|
64
|
+
const pkg = JSON.parse(
|
|
65
|
+
readFileSync(path.join(dir, "package.json"), "utf-8"),
|
|
66
|
+
) as { name?: unknown };
|
|
67
|
+
if (pkg.name === CLI_NAME) return dir;
|
|
68
|
+
} catch {
|
|
69
|
+
// Keep walking; compiled installs usually start under dist/.
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parent = path.dirname(dir);
|
|
73
|
+
if (parent === dir) return null;
|
|
74
|
+
dir = parent;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function npmPrefixFromPackageDir(pkgDir: string): string | null {
|
|
79
|
+
const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
80
|
+
const segments = normalized.split("/");
|
|
81
|
+
const nodeModulesIndex = segments.lastIndexOf("node_modules");
|
|
82
|
+
if (nodeModulesIndex === -1) return null;
|
|
83
|
+
|
|
84
|
+
const prefixEnd =
|
|
85
|
+
segments[nodeModulesIndex - 1] === "lib"
|
|
86
|
+
? nodeModulesIndex - 1
|
|
87
|
+
: nodeModulesIndex;
|
|
88
|
+
const prefix = segments.slice(0, prefixEnd).join("/");
|
|
89
|
+
if (prefix === "" && normalized.startsWith("/")) return "/";
|
|
90
|
+
return prefix || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function resolveRunningPrefix(): string | null {
|
|
94
|
+
try {
|
|
95
|
+
const pkgRoot = findRunningPackageRoot();
|
|
96
|
+
if (!pkgRoot) return null;
|
|
97
|
+
return npmPrefixFromPackageDir(pkgRoot);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildPrefixedInstallArgv(prefix: string): string[] {
|
|
104
|
+
return ["install", "-g", "--prefix", prefix, LATEST_PACKAGE_SPEC];
|
|
105
|
+
}
|
|
106
|
+
|
|
56
107
|
/**
|
|
57
108
|
* Hit POST /v1/client-version/check. Returns the parsed body on 200, or
|
|
58
109
|
* `null` on any failure (caller treats as "no gate"). Tight 3s timeout —
|
|
@@ -95,13 +146,10 @@ async function fetchVersionDecision(): Promise<VersionCheckResponse | null> {
|
|
|
95
146
|
* forcing a re-invocation would run twice on the same process and feel
|
|
96
147
|
* janky; instead we print a clear "rerun your command" message and exit.
|
|
97
148
|
*/
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (parts.length === 0) return { ok: false, detail: "empty command" };
|
|
103
|
-
const cmd = parts[0]!;
|
|
104
|
-
const args = parts.slice(1);
|
|
149
|
+
type UpdateResult = { ok: boolean; detail?: string };
|
|
150
|
+
type UpdateRunner = (cmd: string, args: string[]) => UpdateResult;
|
|
151
|
+
|
|
152
|
+
function runUpdateCommand(cmd: string, args: string[]): UpdateResult {
|
|
105
153
|
try {
|
|
106
154
|
const result = spawnSync(cmd, args, { stdio: "inherit" });
|
|
107
155
|
if (result.status !== 0) {
|
|
@@ -116,6 +164,25 @@ function performUpdate(
|
|
|
116
164
|
}
|
|
117
165
|
}
|
|
118
166
|
|
|
167
|
+
function performUpdateCommand(
|
|
168
|
+
cmd: string,
|
|
169
|
+
args: string[],
|
|
170
|
+
runner: UpdateRunner = runUpdateCommand,
|
|
171
|
+
): UpdateResult {
|
|
172
|
+
return runner(cmd, args);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function performUpdate(
|
|
176
|
+
command: string,
|
|
177
|
+
runner: UpdateRunner = runUpdateCommand,
|
|
178
|
+
): UpdateResult {
|
|
179
|
+
const parts = command.split(/\s+/).filter(Boolean);
|
|
180
|
+
if (parts.length === 0) return { ok: false, detail: "empty command" };
|
|
181
|
+
const cmd = parts[0]!;
|
|
182
|
+
const args = parts.slice(1);
|
|
183
|
+
return performUpdateCommand(cmd, args, runner);
|
|
184
|
+
}
|
|
185
|
+
|
|
119
186
|
/**
|
|
120
187
|
* Soft notify when the server says we're below `latestVersion` but still ≥
|
|
121
188
|
* `minVersion`. Single chalk-yellow line on stderr; never blocks.
|
|
@@ -140,7 +207,14 @@ function nudgeUpdateRecommended(decision: VersionCheckResponse): void {
|
|
|
140
207
|
* 0 — update succeeded; user must rerun their command
|
|
141
208
|
* 75 — update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
|
|
142
209
|
*/
|
|
143
|
-
function enforceUpdateRequired(
|
|
210
|
+
function enforceUpdateRequired(
|
|
211
|
+
decision: VersionCheckResponse,
|
|
212
|
+
deps: {
|
|
213
|
+
performUpdateString?: (command: string) => UpdateResult;
|
|
214
|
+
resolvePrefix?: () => string | null;
|
|
215
|
+
runner?: UpdateRunner;
|
|
216
|
+
} = {},
|
|
217
|
+
): never {
|
|
144
218
|
const banner = chalk.red.bold(
|
|
145
219
|
`✗ hq-cli ${decision.currentVersion} is below the minimum required version (${decision.minVersion}).`,
|
|
146
220
|
);
|
|
@@ -148,7 +222,8 @@ function enforceUpdateRequired(decision: VersionCheckResponse): never {
|
|
|
148
222
|
if (decision.message) console.error(chalk.dim(` ${decision.message}`));
|
|
149
223
|
|
|
150
224
|
const command = decision.updateCommand;
|
|
151
|
-
|
|
225
|
+
const prefix = (deps.resolvePrefix ?? resolveRunningPrefix)();
|
|
226
|
+
if (!command && !prefix) {
|
|
152
227
|
console.error(
|
|
153
228
|
chalk.red(
|
|
154
229
|
" No updateCommand provided by hq-pro — see https://hq.indigo.ai/docs/cli-update for manual steps.",
|
|
@@ -160,13 +235,28 @@ function enforceUpdateRequired(decision: VersionCheckResponse): never {
|
|
|
160
235
|
process.exit(75);
|
|
161
236
|
}
|
|
162
237
|
|
|
163
|
-
|
|
164
|
-
const result =
|
|
238
|
+
const runner = deps.runner ?? runUpdateCommand;
|
|
239
|
+
const result = prefix
|
|
240
|
+
? (() => {
|
|
241
|
+
const args = buildPrefixedInstallArgv(prefix);
|
|
242
|
+
console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
|
|
243
|
+
console.error(chalk.dim(` Running: npm ${args.join(" ")}`));
|
|
244
|
+
return performUpdateCommand("npm", args, runner);
|
|
245
|
+
})()
|
|
246
|
+
: (() => {
|
|
247
|
+
console.error(chalk.dim(` Running: ${command}`));
|
|
248
|
+
return deps.performUpdateString
|
|
249
|
+
? deps.performUpdateString(command!)
|
|
250
|
+
: performUpdate(command!, runner);
|
|
251
|
+
})();
|
|
165
252
|
if (!result.ok) {
|
|
166
253
|
console.error(
|
|
167
254
|
chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`),
|
|
168
255
|
);
|
|
169
|
-
|
|
256
|
+
const manual = prefix
|
|
257
|
+
? `npm ${buildPrefixedInstallArgv(prefix).join(" ")}`
|
|
258
|
+
: command!;
|
|
259
|
+
console.error(chalk.dim(` Try manually: ${manual}`));
|
|
170
260
|
process.exit(75);
|
|
171
261
|
}
|
|
172
262
|
|
|
@@ -215,5 +305,11 @@ export const __test__ = {
|
|
|
215
305
|
CLIENT_ID,
|
|
216
306
|
ENDPOINT_PATH,
|
|
217
307
|
FETCH_TIMEOUT_MS,
|
|
308
|
+
buildPrefixedInstallArgv,
|
|
309
|
+
enforceUpdateRequired,
|
|
310
|
+
npmPrefixFromPackageDir,
|
|
218
311
|
performUpdate,
|
|
312
|
+
performUpdateCommand,
|
|
313
|
+
runUpdateCommand,
|
|
314
|
+
resolveRunningPrefix,
|
|
219
315
|
};
|