@indigoai-us/hq-cli 5.77.0 → 5.77.2
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 +17 -0
- package/dist/commands/auth.js +25 -6
- package/dist/commands/cloud.d.ts +7 -0
- package/dist/commands/cloud.js +21 -0
- package/dist/commands/company.js +9 -3
- package/dist/main.js +8 -1
- package/dist/utils/settle-with-timeout.d.ts +7 -0
- package/dist/utils/settle-with-timeout.js +22 -0
- package/dist/utils/vault-api.js +3 -0
- package/package.json +1 -1
- package/src/commands/auth.test.ts +82 -0
- package/src/commands/auth.ts +32 -9
- package/src/commands/cloud.test.ts +41 -0
- package/src/commands/cloud.ts +33 -0
- package/src/commands/company.test.ts +25 -0
- package/src/commands/company.ts +21 -5
- package/src/main.ts +12 -1
- package/src/utils/settle-with-timeout.test.ts +21 -0
- package/src/utils/settle-with-timeout.ts +22 -0
- package/src/utils/vault-api.test.ts +14 -0
- package/src/utils/vault-api.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.77.2]
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq auth login` now recognizes callback-port collisions and provides recovery
|
|
10
|
+
guidance instead of surfacing a raw bind exception.
|
|
11
|
+
- `hq company settings set --accounting-enabled <true|false>` now forwards the
|
|
12
|
+
accounting setting to the server, restoring the documented accounting
|
|
13
|
+
activation path.
|
|
14
|
+
|
|
15
|
+
## [5.77.1]
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Missing company-slug responses are classified as expected user errors, so the
|
|
20
|
+
CLI no longer sends this expected absence to Sentry.
|
|
21
|
+
|
|
5
22
|
## [5.71.0]
|
|
6
23
|
|
|
7
24
|
### Added
|
package/dist/commands/auth.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import chalk from "chalk";
|
|
17
17
|
import { browserLogin, clearCachedTokens, loadCachedTokens, isExpiring, isMachineIdentity, loadMachineCreds, CognitoAuthError, } from "@indigoai-us/hq-cloud";
|
|
18
|
-
import { refreshCachedSession, } from "../utils/cognito-session.js";
|
|
18
|
+
import { DEFAULT_COGNITO, refreshCachedSession, } from "../utils/cognito-session.js";
|
|
19
19
|
import { cognitoConfigForLoginProvider } from "../utils/login-provider.js";
|
|
20
20
|
/**
|
|
21
21
|
* Decode the (unverified) ID token payload for display purposes only.
|
|
@@ -46,6 +46,17 @@ function machineIdentityLabel() {
|
|
|
46
46
|
const creds = loadMachineCreds();
|
|
47
47
|
return creds ? `machine identity ${creds.username}` : "machine identity";
|
|
48
48
|
}
|
|
49
|
+
function isCallbackPortCollision(error) {
|
|
50
|
+
if (!error || typeof error !== "object")
|
|
51
|
+
return false;
|
|
52
|
+
const { code, message } = error;
|
|
53
|
+
return code === "EADDRINUSE" || message?.includes("EADDRINUSE") === true;
|
|
54
|
+
}
|
|
55
|
+
function callbackPortCollisionGuidance(port) {
|
|
56
|
+
return (` The browser-login callback port (127.0.0.1:${port}) is already in use. ` +
|
|
57
|
+
"Another `hq auth login` may still be waiting for browser sign-in. Finish that login, " +
|
|
58
|
+
"or stop its terminal/process, then retry.");
|
|
59
|
+
}
|
|
49
60
|
export function registerAuthCommands(program) {
|
|
50
61
|
const authCmd = program
|
|
51
62
|
.command("auth")
|
|
@@ -72,13 +83,21 @@ export function registerAuthCommands(program) {
|
|
|
72
83
|
console.log(chalk.dim(` Token cached at ~/.hq/cognito-tokens.json (expires ${tokens.expiresAt})`));
|
|
73
84
|
}
|
|
74
85
|
catch (err) {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
const callbackPortCollision = isCallbackPortCollision(err);
|
|
87
|
+
const msg = callbackPortCollision
|
|
88
|
+
? "Browser-login callback port is already in use."
|
|
89
|
+
: err instanceof CognitoAuthError
|
|
78
90
|
? err.message
|
|
79
|
-
:
|
|
91
|
+
: err instanceof Error
|
|
92
|
+
? err.message
|
|
93
|
+
: String(err);
|
|
80
94
|
console.error(chalk.red(`Login failed: ${msg}`));
|
|
81
|
-
|
|
95
|
+
if (callbackPortCollision) {
|
|
96
|
+
console.error(chalk.dim(callbackPortCollisionGuidance(DEFAULT_COGNITO.port ?? 8765)));
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
console.error(chalk.dim(" If you do not have an account, sign up at https://onboarding.indigo-hq.com"));
|
|
100
|
+
}
|
|
82
101
|
process.exit(1);
|
|
83
102
|
}
|
|
84
103
|
});
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -22,6 +22,13 @@ import { type BannerLevel } from "../lib/narrow-hint-banner.js";
|
|
|
22
22
|
* succeed when files were dropped" guarantee unit-testable.
|
|
23
23
|
*/
|
|
24
24
|
export declare function scopeExcludedWarning(count: number): string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Bound foreground waits for a watcher/manual sync that currently owns the
|
|
27
|
+
* per-root operation lock. The cloud engine reads this environment value on
|
|
28
|
+
* every lock acquisition. A command-line value wins; otherwise preserve a
|
|
29
|
+
* valid explicit caller environment value and supply a finite CLI default.
|
|
30
|
+
*/
|
|
31
|
+
export declare function configureSyncLockTimeout(raw: string | undefined): void;
|
|
25
32
|
export interface PullAllVaultClient {
|
|
26
33
|
listMyMemberships(): Promise<Array<{
|
|
27
34
|
companyUid: string;
|
package/dist/commands/cloud.js
CHANGED
|
@@ -59,6 +59,21 @@ function resolveDeletePolicy() {
|
|
|
59
59
|
}
|
|
60
60
|
return "currency-gated";
|
|
61
61
|
}
|
|
62
|
+
const DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS = 300;
|
|
63
|
+
/**
|
|
64
|
+
* Bound foreground waits for a watcher/manual sync that currently owns the
|
|
65
|
+
* per-root operation lock. The cloud engine reads this environment value on
|
|
66
|
+
* every lock acquisition. A command-line value wins; otherwise preserve a
|
|
67
|
+
* valid explicit caller environment value and supply a finite CLI default.
|
|
68
|
+
*/
|
|
69
|
+
export function configureSyncLockTimeout(raw) {
|
|
70
|
+
const value = raw ?? process.env.HQ_OP_LOCK_TIMEOUT ?? String(DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS);
|
|
71
|
+
const seconds = Number(value);
|
|
72
|
+
if (!Number.isInteger(seconds) || seconds < 0) {
|
|
73
|
+
throw new Error("--lock-timeout must be a non-negative integer number of seconds");
|
|
74
|
+
}
|
|
75
|
+
process.env.HQ_OP_LOCK_TIMEOUT = String(seconds);
|
|
76
|
+
}
|
|
62
77
|
// Oldest-first by createdAt, ties broken by uid lexicographic — matches
|
|
63
78
|
// `pickCanonicalPersonEntity` in @indigoai-us/hq-cloud so the CLI lands on
|
|
64
79
|
// the same person bucket that `hq-sync-runner` picks.
|
|
@@ -478,6 +493,7 @@ export function registerCloudCommands(program) {
|
|
|
478
493
|
.argument("[paths...]", "Paths to push (defaults to current directory)")
|
|
479
494
|
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
480
495
|
.option("--company <slug>", "Company slug or UID (defaults to active company in .hq/config.json)")
|
|
496
|
+
.option("--lock-timeout <seconds>", "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)")
|
|
481
497
|
.option("--message <msg>", "Optional message attached to journal entries for these uploads")
|
|
482
498
|
.option("--on-conflict <strategy>", "Conflict strategy: overwrite | keep | abort (omit for interactive)")
|
|
483
499
|
.option("--creds-from-stdin", "Read a pre-vended EntityContext as JSON from stdin instead of vending " +
|
|
@@ -509,6 +525,7 @@ export function registerCloudCommands(program) {
|
|
|
509
525
|
"`--all`.")
|
|
510
526
|
.action(async (paths, options) => {
|
|
511
527
|
try {
|
|
528
|
+
configureSyncLockTimeout(options.lockTimeout);
|
|
512
529
|
assertSingleSelector(options, "push");
|
|
513
530
|
}
|
|
514
531
|
catch (err) {
|
|
@@ -688,6 +705,7 @@ export function registerCloudCommands(program) {
|
|
|
688
705
|
.description("Pull permitted files from the company vault to local HQ")
|
|
689
706
|
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
690
707
|
.option("--company <slug>", "Company slug or UID (defaults to active company in .hq/config.json)")
|
|
708
|
+
.option("--lock-timeout <seconds>", "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)")
|
|
691
709
|
.option("--on-conflict <strategy>", "Conflict strategy: overwrite | keep | abort (omit for interactive)")
|
|
692
710
|
.option("--all", "Pull every company you are a member of plus your personal vault " +
|
|
693
711
|
"into <hq-root>. Companies land at <hq-root>/companies/<slug>; " +
|
|
@@ -711,6 +729,7 @@ export function registerCloudCommands(program) {
|
|
|
711
729
|
"quarantined under .hq/scope-quarantine/ (recoverable).")
|
|
712
730
|
.action(async (options) => {
|
|
713
731
|
try {
|
|
732
|
+
configureSyncLockTimeout(options.lockTimeout);
|
|
714
733
|
assertSingleSelector(options, "pull");
|
|
715
734
|
}
|
|
716
735
|
catch (err) {
|
|
@@ -882,6 +901,7 @@ export function registerCloudCommands(program) {
|
|
|
882
901
|
"(mirrors AppBar HQ Sync's \"Sync Now\" button)")
|
|
883
902
|
.option("--hq-root <path>", `Local HQ tree root (default: ${DEFAULT_HQ_ROOT})`, DEFAULT_HQ_ROOT)
|
|
884
903
|
.option("--company <slug>", "Company slug or UID (defaults to active company in .hq/config.json)")
|
|
904
|
+
.option("--lock-timeout <seconds>", "Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)")
|
|
885
905
|
.option("--message <msg>", "Optional message attached to journal entries for the push leg")
|
|
886
906
|
.option("--on-conflict <strategy>", "Conflict strategy: overwrite | keep | abort (omit for interactive)")
|
|
887
907
|
.option("--all", "Sync every company you are a member of plus your personal vault " +
|
|
@@ -901,6 +921,7 @@ export function registerCloudCommands(program) {
|
|
|
901
921
|
"out-of-scope files are quarantined under .hq/scope-quarantine/.")
|
|
902
922
|
.action(async (options) => {
|
|
903
923
|
try {
|
|
924
|
+
configureSyncLockTimeout(options.lockTimeout);
|
|
904
925
|
assertSingleSelector(options, "now");
|
|
905
926
|
if (options.all) {
|
|
906
927
|
// `options.personal === false` is Commander's auto-negation
|
package/dist/commands/company.js
CHANGED
|
@@ -25,9 +25,10 @@ export function registerCompanyCommand(program) {
|
|
|
25
25
|
settings
|
|
26
26
|
.command("set")
|
|
27
27
|
.description("Set per-company settings (PUT /company-settings). Require at least one " +
|
|
28
|
-
"of --crm-enabled / --ontology-enabled.")
|
|
28
|
+
"of --crm-enabled / --ontology-enabled / --accounting-enabled.")
|
|
29
29
|
.option("--crm-enabled <true|false>", "Enable/disable the native CRM for this company")
|
|
30
30
|
.option("--ontology-enabled <true|false>", "Enable/disable the ontology gardener for this company")
|
|
31
|
+
.option("--accounting-enabled <true|false>", "Enable/disable the accounting pack for this company")
|
|
31
32
|
.action(async (opts) => {
|
|
32
33
|
try {
|
|
33
34
|
const companySlug = company.opts().company;
|
|
@@ -35,8 +36,10 @@ export function registerCompanyCommand(program) {
|
|
|
35
36
|
console.error(chalk.red("Error: --company <slug> is required."));
|
|
36
37
|
process.exit(1);
|
|
37
38
|
}
|
|
38
|
-
if (opts.crmEnabled === undefined &&
|
|
39
|
-
|
|
39
|
+
if (opts.crmEnabled === undefined &&
|
|
40
|
+
opts.ontologyEnabled === undefined &&
|
|
41
|
+
opts.accountingEnabled === undefined) {
|
|
42
|
+
console.error(chalk.red("Error: provide at least one of --crm-enabled / --ontology-enabled / --accounting-enabled."));
|
|
40
43
|
process.exit(1);
|
|
41
44
|
}
|
|
42
45
|
const body = {};
|
|
@@ -46,6 +49,9 @@ export function registerCompanyCommand(program) {
|
|
|
46
49
|
if (opts.ontologyEnabled !== undefined) {
|
|
47
50
|
body.ontologyEnabled = parseBoolFlag(opts.ontologyEnabled, "--ontology-enabled");
|
|
48
51
|
}
|
|
52
|
+
if (opts.accountingEnabled !== undefined) {
|
|
53
|
+
body.accountingEnabled = parseBoolFlag(opts.accountingEnabled, "--accounting-enabled");
|
|
54
|
+
}
|
|
49
55
|
const token = await ensureCognitoToken();
|
|
50
56
|
const companyUid = await getEntityUid(token, { companySlug });
|
|
51
57
|
body.companyUid = companyUid;
|
package/dist/main.js
CHANGED
|
@@ -67,6 +67,9 @@ import { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check
|
|
|
67
67
|
import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
|
|
68
68
|
import { CLI_VERSION } from "./cli-version.js";
|
|
69
69
|
import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
|
|
70
|
+
import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
71
|
+
/** Hard upper bound for non-user-visible release-health finalization. */
|
|
72
|
+
const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
|
|
70
73
|
// Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes
|
|
71
74
|
// the pipe early. This covers the ASYNC path — an 'error' event emitted on the
|
|
72
75
|
// stream. The SYNCHRONOUS path (a `write EPIPE` thrown straight out of
|
|
@@ -306,7 +309,11 @@ export async function runCli() {
|
|
|
306
309
|
finally {
|
|
307
310
|
// Release health: finalize the per-run session before the flush.
|
|
308
311
|
Sentry.endSession();
|
|
309
|
-
|
|
312
|
+
// Neither task may turn a successful command into Node's
|
|
313
|
+
// `unsettled top-level await` exit. They are observability-only after the
|
|
314
|
+
// command has completed, so a bounded best-effort wait is the terminal
|
|
315
|
+
// lifecycle boundary for this invocation.
|
|
316
|
+
await settleWithin([refreshVersionCache(), Sentry.flush(2000)], RELEASE_HEALTH_SETTLE_TIMEOUT_MS);
|
|
310
317
|
}
|
|
311
318
|
}
|
|
312
319
|
//# sourceMappingURL=main.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Await finalization work without allowing a best-effort promise to leave a
|
|
3
|
+
* CLI's top-level await pending forever. The timer intentionally stays
|
|
4
|
+
* referenced: Node must remain alive long enough to settle this race.
|
|
5
|
+
*/
|
|
6
|
+
export declare function settleWithin(promises: readonly Promise<unknown>[], timeoutMs: number): Promise<"settled" | "timed_out">;
|
|
7
|
+
//# sourceMappingURL=settle-with-timeout.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Await finalization work without allowing a best-effort promise to leave a
|
|
3
|
+
* CLI's top-level await pending forever. The timer intentionally stays
|
|
4
|
+
* referenced: Node must remain alive long enough to settle this race.
|
|
5
|
+
*/
|
|
6
|
+
export async function settleWithin(promises, timeoutMs) {
|
|
7
|
+
let timer;
|
|
8
|
+
const timedOut = new Promise((resolve) => {
|
|
9
|
+
timer = setTimeout(() => resolve("timed_out"), timeoutMs);
|
|
10
|
+
});
|
|
11
|
+
try {
|
|
12
|
+
return await Promise.race([
|
|
13
|
+
Promise.allSettled(promises).then(() => "settled"),
|
|
14
|
+
timedOut,
|
|
15
|
+
]);
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
if (timer !== undefined)
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=settle-with-timeout.js.map
|
package/dist/utils/vault-api.js
CHANGED
|
@@ -176,6 +176,9 @@ async function resolveCompanyUid(token, ref) {
|
|
|
176
176
|
`is in your namespace. Re-run with --company <uid> to pick one:\n` +
|
|
177
177
|
body.uids.map((u) => ` --company ${u}`).join('\n'));
|
|
178
178
|
}
|
|
179
|
+
if (res.status === 404 && body.error === "Entity not found") {
|
|
180
|
+
throw Object.assign(new Error(`Company slug '${ref}' was not found. Check the slug or run \`hq companies list\`.`), { expected: true });
|
|
181
|
+
}
|
|
179
182
|
throw new Error(`Failed to resolve company slug '${ref}': ${body.error ?? res.statusText}`);
|
|
180
183
|
}
|
|
181
184
|
const data = (await res.json());
|
package/package.json
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
browserLogin: vi.fn(),
|
|
6
|
+
isExpiring: vi.fn(),
|
|
7
|
+
isMachineIdentity: vi.fn(),
|
|
8
|
+
loadCachedTokens: vi.fn(),
|
|
9
|
+
loadMachineCreds: vi.fn(),
|
|
10
|
+
CognitoAuthError: class TestCognitoAuthError extends Error {},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("@indigoai-us/hq-cloud", () => ({
|
|
14
|
+
browserLogin: mocks.browserLogin,
|
|
15
|
+
clearCachedTokens: vi.fn(),
|
|
16
|
+
isExpiring: mocks.isExpiring,
|
|
17
|
+
isMachineIdentity: mocks.isMachineIdentity,
|
|
18
|
+
loadCachedTokens: mocks.loadCachedTokens,
|
|
19
|
+
loadMachineCreds: mocks.loadMachineCreds,
|
|
20
|
+
CognitoAuthError: mocks.CognitoAuthError,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock("../utils/cognito-session.js", () => ({
|
|
24
|
+
DEFAULT_COGNITO: {
|
|
25
|
+
region: "us-east-1",
|
|
26
|
+
userPoolDomain: "vault-indigo-hq-prod",
|
|
27
|
+
clientId: "client-123",
|
|
28
|
+
port: 8765,
|
|
29
|
+
identityProvider: "Google",
|
|
30
|
+
},
|
|
31
|
+
refreshCachedSession: vi.fn(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
import { registerAuthCommands } from "./auth.js";
|
|
35
|
+
|
|
36
|
+
class ProcessExitError extends Error {
|
|
37
|
+
constructor(readonly code: number) {
|
|
38
|
+
super(`process.exit(${code})`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("hq auth login", () => {
|
|
43
|
+
it("REGRESSION: explains how to recover when the callback port is held by another login", async () => {
|
|
44
|
+
mocks.isMachineIdentity.mockReturnValue(false);
|
|
45
|
+
mocks.loadCachedTokens.mockReturnValue(undefined);
|
|
46
|
+
mocks.browserLogin.mockRejectedValue(
|
|
47
|
+
Object.assign(new Error("listen EADDRINUSE: address already in use 127.0.0.1:8765"), {
|
|
48
|
+
code: "EADDRINUSE",
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const errors: string[] = [];
|
|
53
|
+
const errorSpy = vi
|
|
54
|
+
.spyOn(console, "error")
|
|
55
|
+
.mockImplementation((...args: unknown[]) => errors.push(args.map(String).join(" ")));
|
|
56
|
+
const exitSpy = vi
|
|
57
|
+
.spyOn(process, "exit")
|
|
58
|
+
.mockImplementation(((code?: number) => {
|
|
59
|
+
throw new ProcessExitError(code ?? 0);
|
|
60
|
+
}) as never);
|
|
61
|
+
const program = new Command();
|
|
62
|
+
program.exitOverride();
|
|
63
|
+
registerAuthCommands(program);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await expect(program.parseAsync(["auth", "login"], { from: "user" })).rejects.toMatchObject({
|
|
67
|
+
code: 1,
|
|
68
|
+
});
|
|
69
|
+
} finally {
|
|
70
|
+
errorSpy.mockRestore();
|
|
71
|
+
exitSpy.mockRestore();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const output = errors.join("\n");
|
|
75
|
+
expect(output).toContain("Login failed: Browser-login callback port is already in use.");
|
|
76
|
+
expect(output).not.toContain("EADDRINUSE");
|
|
77
|
+
expect(output).toContain("callback port (127.0.0.1:8765) is already in use");
|
|
78
|
+
expect(output).toContain("Another `hq auth login` may still be waiting");
|
|
79
|
+
expect(output).toContain("stop its terminal/process, then retry");
|
|
80
|
+
expect(output).not.toContain("sign up at");
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/commands/auth.ts
CHANGED
|
@@ -66,6 +66,20 @@ function machineIdentityLabel(): string {
|
|
|
66
66
|
return creds ? `machine identity ${creds.username}` : "machine identity";
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
function isCallbackPortCollision(error: unknown): boolean {
|
|
70
|
+
if (!error || typeof error !== "object") return false;
|
|
71
|
+
const { code, message } = error as NodeJS.ErrnoException;
|
|
72
|
+
return code === "EADDRINUSE" || message?.includes("EADDRINUSE") === true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function callbackPortCollisionGuidance(port: number): string {
|
|
76
|
+
return (
|
|
77
|
+
` The browser-login callback port (127.0.0.1:${port}) is already in use. ` +
|
|
78
|
+
"Another `hq auth login` may still be waiting for browser sign-in. Finish that login, " +
|
|
79
|
+
"or stop its terminal/process, then retry."
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
69
83
|
export function registerAuthCommands(program: Command): void {
|
|
70
84
|
const authCmd = program
|
|
71
85
|
.command("auth")
|
|
@@ -105,18 +119,27 @@ export function registerAuthCommands(program: Command): void {
|
|
|
105
119
|
chalk.dim(` Token cached at ~/.hq/cognito-tokens.json (expires ${tokens.expiresAt})`),
|
|
106
120
|
);
|
|
107
121
|
} catch (err) {
|
|
122
|
+
const callbackPortCollision = isCallbackPortCollision(err);
|
|
108
123
|
const msg =
|
|
109
|
-
|
|
110
|
-
?
|
|
111
|
-
: err instanceof
|
|
124
|
+
callbackPortCollision
|
|
125
|
+
? "Browser-login callback port is already in use."
|
|
126
|
+
: err instanceof CognitoAuthError
|
|
112
127
|
? err.message
|
|
113
|
-
:
|
|
128
|
+
: err instanceof Error
|
|
129
|
+
? err.message
|
|
130
|
+
: String(err);
|
|
114
131
|
console.error(chalk.red(`Login failed: ${msg}`));
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
)
|
|
119
|
-
|
|
132
|
+
if (callbackPortCollision) {
|
|
133
|
+
console.error(
|
|
134
|
+
chalk.dim(callbackPortCollisionGuidance(DEFAULT_COGNITO.port ?? 8765)),
|
|
135
|
+
);
|
|
136
|
+
} else {
|
|
137
|
+
console.error(
|
|
138
|
+
chalk.dim(
|
|
139
|
+
" If you do not have an account, sign up at https://onboarding.indigo-hq.com",
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
120
143
|
process.exit(1);
|
|
121
144
|
}
|
|
122
145
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { configureSyncLockTimeout } from "./cloud.js";
|
|
4
|
+
|
|
5
|
+
const originalLockTimeout = process.env.HQ_OP_LOCK_TIMEOUT;
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
if (originalLockTimeout === undefined) {
|
|
9
|
+
delete process.env.HQ_OP_LOCK_TIMEOUT;
|
|
10
|
+
} else {
|
|
11
|
+
process.env.HQ_OP_LOCK_TIMEOUT = originalLockTimeout;
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("configureSyncLockTimeout", () => {
|
|
16
|
+
it("supplies a finite foreground wait when the caller has not configured one", () => {
|
|
17
|
+
delete process.env.HQ_OP_LOCK_TIMEOUT;
|
|
18
|
+
configureSyncLockTimeout(undefined);
|
|
19
|
+
expect(process.env.HQ_OP_LOCK_TIMEOUT).toBe("300");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("honors an explicit zero-second refusal", () => {
|
|
23
|
+
configureSyncLockTimeout("0");
|
|
24
|
+
expect(process.env.HQ_OP_LOCK_TIMEOUT).toBe("0");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("honors a valid inherited lock timeout", () => {
|
|
28
|
+
process.env.HQ_OP_LOCK_TIMEOUT = "17";
|
|
29
|
+
configureSyncLockTimeout(undefined);
|
|
30
|
+
expect(process.env.HQ_OP_LOCK_TIMEOUT).toBe("17");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rejects an invalid timeout instead of silently waiting forever", () => {
|
|
34
|
+
expect(() => configureSyncLockTimeout("forever")).toThrow("--lock-timeout");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects an invalid inherited timeout instead of restoring an infinite wait", () => {
|
|
38
|
+
process.env.HQ_OP_LOCK_TIMEOUT = "forever";
|
|
39
|
+
expect(() => configureSyncLockTimeout(undefined)).toThrow("--lock-timeout");
|
|
40
|
+
});
|
|
41
|
+
});
|
package/src/commands/cloud.ts
CHANGED
|
@@ -97,6 +97,24 @@ function resolveDeletePolicy(): "owned-only" | "currency-gated" | "all" {
|
|
|
97
97
|
interface CommonSyncOptions {
|
|
98
98
|
hqRoot: string;
|
|
99
99
|
company?: string;
|
|
100
|
+
lockTimeout?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS = 300;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Bound foreground waits for a watcher/manual sync that currently owns the
|
|
107
|
+
* per-root operation lock. The cloud engine reads this environment value on
|
|
108
|
+
* every lock acquisition. A command-line value wins; otherwise preserve a
|
|
109
|
+
* valid explicit caller environment value and supply a finite CLI default.
|
|
110
|
+
*/
|
|
111
|
+
export function configureSyncLockTimeout(raw: string | undefined): void {
|
|
112
|
+
const value = raw ?? process.env.HQ_OP_LOCK_TIMEOUT ?? String(DEFAULT_SYNC_LOCK_TIMEOUT_SECONDS);
|
|
113
|
+
const seconds = Number(value);
|
|
114
|
+
if (!Number.isInteger(seconds) || seconds < 0) {
|
|
115
|
+
throw new Error("--lock-timeout must be a non-negative integer number of seconds");
|
|
116
|
+
}
|
|
117
|
+
process.env.HQ_OP_LOCK_TIMEOUT = String(seconds);
|
|
100
118
|
}
|
|
101
119
|
|
|
102
120
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -821,6 +839,10 @@ export function registerCloudCommands(program: Command): void {
|
|
|
821
839
|
"--company <slug>",
|
|
822
840
|
"Company slug or UID (defaults to active company in .hq/config.json)",
|
|
823
841
|
)
|
|
842
|
+
.option(
|
|
843
|
+
"--lock-timeout <seconds>",
|
|
844
|
+
"Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)",
|
|
845
|
+
)
|
|
824
846
|
.option(
|
|
825
847
|
"--message <msg>",
|
|
826
848
|
"Optional message attached to journal entries for these uploads",
|
|
@@ -884,6 +906,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
884
906
|
},
|
|
885
907
|
) => {
|
|
886
908
|
try {
|
|
909
|
+
configureSyncLockTimeout(options.lockTimeout);
|
|
887
910
|
assertSingleSelector(options, "push");
|
|
888
911
|
} catch (err) {
|
|
889
912
|
console.error(
|
|
@@ -1118,6 +1141,10 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1118
1141
|
"--company <slug>",
|
|
1119
1142
|
"Company slug or UID (defaults to active company in .hq/config.json)",
|
|
1120
1143
|
)
|
|
1144
|
+
.option(
|
|
1145
|
+
"--lock-timeout <seconds>",
|
|
1146
|
+
"Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)",
|
|
1147
|
+
)
|
|
1121
1148
|
.option(
|
|
1122
1149
|
"--on-conflict <strategy>",
|
|
1123
1150
|
"Conflict strategy: overwrite | keep | abort (omit for interactive)",
|
|
@@ -1168,6 +1195,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1168
1195
|
},
|
|
1169
1196
|
) => {
|
|
1170
1197
|
try {
|
|
1198
|
+
configureSyncLockTimeout(options.lockTimeout);
|
|
1171
1199
|
assertSingleSelector(options, "pull");
|
|
1172
1200
|
} catch (err) {
|
|
1173
1201
|
console.error(
|
|
@@ -1410,6 +1438,10 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1410
1438
|
"--company <slug>",
|
|
1411
1439
|
"Company slug or UID (defaults to active company in .hq/config.json)",
|
|
1412
1440
|
)
|
|
1441
|
+
.option(
|
|
1442
|
+
"--lock-timeout <seconds>",
|
|
1443
|
+
"Maximum wait for another active HQ operation (default: 300; 0 = refuse immediately)",
|
|
1444
|
+
)
|
|
1413
1445
|
.option(
|
|
1414
1446
|
"--message <msg>",
|
|
1415
1447
|
"Optional message attached to journal entries for the push leg",
|
|
@@ -1460,6 +1492,7 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1460
1492
|
},
|
|
1461
1493
|
) => {
|
|
1462
1494
|
try {
|
|
1495
|
+
configureSyncLockTimeout(options.lockTimeout);
|
|
1463
1496
|
assertSingleSelector(options, "now");
|
|
1464
1497
|
if (options.all) {
|
|
1465
1498
|
// `options.personal === false` is Commander's auto-negation
|
|
@@ -104,6 +104,31 @@ describe("hq company settings set", () => {
|
|
|
104
104
|
expect(sent.ontologyEnabled).toBeUndefined();
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
it("PUTs accountingEnabled=true with the resolved companyUid", async () => {
|
|
108
|
+
fetchSpy.mockResolvedValueOnce(
|
|
109
|
+
jsonResponse(200, { companySettings: { accountingEnabled: true } }),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
await run([
|
|
113
|
+
"company",
|
|
114
|
+
"settings",
|
|
115
|
+
"set",
|
|
116
|
+
"--company",
|
|
117
|
+
"acme",
|
|
118
|
+
"--accounting-enabled",
|
|
119
|
+
"true",
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
123
|
+
const sent = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
|
|
124
|
+
expect(sent).toMatchObject({
|
|
125
|
+
companyUid: "cmp_acme",
|
|
126
|
+
accountingEnabled: true,
|
|
127
|
+
});
|
|
128
|
+
expect(sent.crmEnabled).toBeUndefined();
|
|
129
|
+
expect(sent.ontologyEnabled).toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
|
|
107
132
|
it("PUTs both flags when both are supplied", async () => {
|
|
108
133
|
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
109
134
|
|
package/src/commands/company.ts
CHANGED
|
@@ -7,8 +7,9 @@ import { vaultApiFetch, getEntityUid } from "../utils/vault-api.js";
|
|
|
7
7
|
* `hq company settings set` — owner-only per-company settings toggles
|
|
8
8
|
* (agency group-grants US-007 / hq-native-crm US-003). Wraps
|
|
9
9
|
* `PUT /company-settings`, setting the runtime-flippable `crmEnabled` /
|
|
10
|
-
* `ontologyEnabled` flags so an owner can enable the
|
|
11
|
-
* ontology gardener
|
|
10
|
+
* `ontologyEnabled` / `accountingEnabled` flags so an owner can enable the
|
|
11
|
+
* native CRM, ontology gardener, or accounting pack for a company without a
|
|
12
|
+
* redeploy.
|
|
12
13
|
*
|
|
13
14
|
* Conventions mirror `secrets.ts`: ensureCognitoToken() → getEntityUid() →
|
|
14
15
|
* vaultApiFetch() → error-check → chalk output.
|
|
@@ -17,6 +18,7 @@ import { vaultApiFetch, getEntityUid } from "../utils/vault-api.js";
|
|
|
17
18
|
interface SettingsSetFlags {
|
|
18
19
|
crmEnabled?: string;
|
|
19
20
|
ontologyEnabled?: string;
|
|
21
|
+
accountingEnabled?: string;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -45,7 +47,7 @@ export function registerCompanyCommand(program: Command): void {
|
|
|
45
47
|
.command("set")
|
|
46
48
|
.description(
|
|
47
49
|
"Set per-company settings (PUT /company-settings). Require at least one " +
|
|
48
|
-
"of --crm-enabled / --ontology-enabled.",
|
|
50
|
+
"of --crm-enabled / --ontology-enabled / --accounting-enabled.",
|
|
49
51
|
)
|
|
50
52
|
.option(
|
|
51
53
|
"--crm-enabled <true|false>",
|
|
@@ -55,6 +57,10 @@ export function registerCompanyCommand(program: Command): void {
|
|
|
55
57
|
"--ontology-enabled <true|false>",
|
|
56
58
|
"Enable/disable the ontology gardener for this company",
|
|
57
59
|
)
|
|
60
|
+
.option(
|
|
61
|
+
"--accounting-enabled <true|false>",
|
|
62
|
+
"Enable/disable the accounting pack for this company",
|
|
63
|
+
)
|
|
58
64
|
.action(async (opts: SettingsSetFlags) => {
|
|
59
65
|
try {
|
|
60
66
|
const companySlug = company.opts().company as string | undefined;
|
|
@@ -62,10 +68,14 @@ export function registerCompanyCommand(program: Command): void {
|
|
|
62
68
|
console.error(chalk.red("Error: --company <slug> is required."));
|
|
63
69
|
process.exit(1);
|
|
64
70
|
}
|
|
65
|
-
if (
|
|
71
|
+
if (
|
|
72
|
+
opts.crmEnabled === undefined &&
|
|
73
|
+
opts.ontologyEnabled === undefined &&
|
|
74
|
+
opts.accountingEnabled === undefined
|
|
75
|
+
) {
|
|
66
76
|
console.error(
|
|
67
77
|
chalk.red(
|
|
68
|
-
"Error: provide at least one of --crm-enabled / --ontology-enabled.",
|
|
78
|
+
"Error: provide at least one of --crm-enabled / --ontology-enabled / --accounting-enabled.",
|
|
69
79
|
),
|
|
70
80
|
);
|
|
71
81
|
process.exit(1);
|
|
@@ -81,6 +91,12 @@ export function registerCompanyCommand(program: Command): void {
|
|
|
81
91
|
"--ontology-enabled",
|
|
82
92
|
);
|
|
83
93
|
}
|
|
94
|
+
if (opts.accountingEnabled !== undefined) {
|
|
95
|
+
body.accountingEnabled = parseBoolFlag(
|
|
96
|
+
opts.accountingEnabled,
|
|
97
|
+
"--accounting-enabled",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
84
100
|
|
|
85
101
|
const token = await ensureCognitoToken();
|
|
86
102
|
const companyUid = await getEntityUid(token, { companySlug });
|
package/src/main.ts
CHANGED
|
@@ -75,6 +75,10 @@ import {
|
|
|
75
75
|
} from "./utils/version-gate.js";
|
|
76
76
|
import { CLI_VERSION } from "./cli-version.js";
|
|
77
77
|
import { emitCliSessionStarted } from "./utils/cli-telemetry.js";
|
|
78
|
+
import { settleWithin } from "./utils/settle-with-timeout.js";
|
|
79
|
+
|
|
80
|
+
/** Hard upper bound for non-user-visible release-health finalization. */
|
|
81
|
+
const RELEASE_HEALTH_SETTLE_TIMEOUT_MS = 3_000;
|
|
78
82
|
|
|
79
83
|
// Swallow EPIPE when a downstream reader (e.g. `source <(…)`, `| head`) closes
|
|
80
84
|
// the pipe early. This covers the ASYNC path — an 'error' event emitted on the
|
|
@@ -351,6 +355,13 @@ export async function runCli(): Promise<void> {
|
|
|
351
355
|
} finally {
|
|
352
356
|
// Release health: finalize the per-run session before the flush.
|
|
353
357
|
Sentry.endSession();
|
|
354
|
-
|
|
358
|
+
// Neither task may turn a successful command into Node's
|
|
359
|
+
// `unsettled top-level await` exit. They are observability-only after the
|
|
360
|
+
// command has completed, so a bounded best-effort wait is the terminal
|
|
361
|
+
// lifecycle boundary for this invocation.
|
|
362
|
+
await settleWithin(
|
|
363
|
+
[refreshVersionCache(), Sentry.flush(2000)],
|
|
364
|
+
RELEASE_HEALTH_SETTLE_TIMEOUT_MS,
|
|
365
|
+
);
|
|
355
366
|
}
|
|
356
367
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { settleWithin } from "./settle-with-timeout.js";
|
|
4
|
+
|
|
5
|
+
describe("settleWithin", () => {
|
|
6
|
+
it("settles normally when all finalizers finish", async () => {
|
|
7
|
+
await expect(settleWithin([Promise.resolve(), Promise.reject(new Error("ignored"))], 100))
|
|
8
|
+
.resolves.toBe("settled");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("settles the caller when a best-effort finalizer never resolves", async () => {
|
|
12
|
+
vi.useFakeTimers();
|
|
13
|
+
try {
|
|
14
|
+
const result = settleWithin([new Promise<void>(() => {})], 1_000);
|
|
15
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
16
|
+
await expect(result).resolves.toBe("timed_out");
|
|
17
|
+
} finally {
|
|
18
|
+
vi.useRealTimers();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Await finalization work without allowing a best-effort promise to leave a
|
|
3
|
+
* CLI's top-level await pending forever. The timer intentionally stays
|
|
4
|
+
* referenced: Node must remain alive long enough to settle this race.
|
|
5
|
+
*/
|
|
6
|
+
export async function settleWithin(
|
|
7
|
+
promises: readonly Promise<unknown>[],
|
|
8
|
+
timeoutMs: number,
|
|
9
|
+
): Promise<"settled" | "timed_out"> {
|
|
10
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
11
|
+
const timedOut = new Promise<"timed_out">((resolve) => {
|
|
12
|
+
timer = setTimeout(() => resolve("timed_out"), timeoutMs);
|
|
13
|
+
});
|
|
14
|
+
try {
|
|
15
|
+
return await Promise.race([
|
|
16
|
+
Promise.allSettled(promises).then(() => "settled" as const),
|
|
17
|
+
timedOut,
|
|
18
|
+
]);
|
|
19
|
+
} finally {
|
|
20
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -8,6 +8,7 @@ import { Sentry } from '../sentry.js';
|
|
|
8
8
|
import { getCompanyUid, getEntityUid, resolveCallerPersonUid, vaultApiFetch } from './vault-api.js';
|
|
9
9
|
import { isAuthError } from './auth-error.js';
|
|
10
10
|
import { isCompanySelectionError } from './company-selection-error.js';
|
|
11
|
+
import { isExpectedUserError } from './expected-cli-error.js';
|
|
11
12
|
|
|
12
13
|
const fetchMock = vi.fn();
|
|
13
14
|
const originalFetch = globalThis.fetch;
|
|
@@ -142,6 +143,19 @@ describe('getEntityUid', () => {
|
|
|
142
143
|
expect(fetchMock.mock.calls[1][0]).toMatch(/\/entity\/by-slug\/company\/acme/);
|
|
143
144
|
});
|
|
144
145
|
|
|
146
|
+
it('classifies a global by-slug entity miss as an expected user error (HQ-CLI-8)', async () => {
|
|
147
|
+
fetchMock
|
|
148
|
+
.mockResolvedValueOnce(mockResponse(200, { available: true }))
|
|
149
|
+
.mockResolvedValueOnce(mockResponse(404, { error: 'Entity not found' }));
|
|
150
|
+
|
|
151
|
+
const err = await getEntityUid('tok', { companySlug: 'missing-co' }).catch(
|
|
152
|
+
(e: unknown) => e,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(isExpectedUserError(err)).toBe(true);
|
|
156
|
+
expect((err as Error).message).toMatch(/Company slug 'missing-co' was not found/);
|
|
157
|
+
});
|
|
158
|
+
|
|
145
159
|
it('on residual global ambiguity (not in namespace) gives actionable --company <uid> guidance', async () => {
|
|
146
160
|
// Caller belongs to no "acme"; the slug matches multiple strangers'
|
|
147
161
|
// companies. The server 409s with the colliding uids and the CLI must tell
|
package/src/utils/vault-api.ts
CHANGED
|
@@ -217,6 +217,14 @@ async function resolveCompanyUid(token: string, ref: string): Promise<string> {
|
|
|
217
217
|
body.uids.map((u) => ` --company ${u}`).join('\n'),
|
|
218
218
|
);
|
|
219
219
|
}
|
|
220
|
+
if (res.status === 404 && body.error === "Entity not found") {
|
|
221
|
+
throw Object.assign(
|
|
222
|
+
new Error(
|
|
223
|
+
`Company slug '${ref}' was not found. Check the slug or run \`hq companies list\`.`,
|
|
224
|
+
),
|
|
225
|
+
{ expected: true as const },
|
|
226
|
+
);
|
|
227
|
+
}
|
|
220
228
|
throw new Error(
|
|
221
229
|
`Failed to resolve company slug '${ref}': ${body.error ?? res.statusText}`,
|
|
222
230
|
);
|