@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
|
@@ -28,17 +28,66 @@
|
|
|
28
28
|
* to silence both check + gate).
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
31
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="0f613895-ad4a-5211-bf75-a2e843782c47")}catch(e){}}();
|
|
32
32
|
import { spawnSync } from "node:child_process";
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import { fileURLToPath } from "node:url";
|
|
33
36
|
import chalk from "chalk";
|
|
34
|
-
import { CLI_VERSION } from "../cli-version.js";
|
|
37
|
+
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
35
38
|
import { DEFAULT_VAULT_API_URL } from "./cognito-session.js";
|
|
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
|
function isOptedOut() {
|
|
40
44
|
return process.env.HQ_NO_UPDATE_CHECK === "1";
|
|
41
45
|
}
|
|
46
|
+
function findRunningPackageRoot() {
|
|
47
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
while (true) {
|
|
49
|
+
try {
|
|
50
|
+
const pkg = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf-8"));
|
|
51
|
+
if (pkg.name === CLI_NAME)
|
|
52
|
+
return dir;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Keep walking; compiled installs usually start under dist/.
|
|
56
|
+
}
|
|
57
|
+
const parent = path.dirname(dir);
|
|
58
|
+
if (parent === dir)
|
|
59
|
+
return null;
|
|
60
|
+
dir = parent;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function npmPrefixFromPackageDir(pkgDir) {
|
|
64
|
+
const normalized = pkgDir.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
65
|
+
const segments = normalized.split("/");
|
|
66
|
+
const nodeModulesIndex = segments.lastIndexOf("node_modules");
|
|
67
|
+
if (nodeModulesIndex === -1)
|
|
68
|
+
return null;
|
|
69
|
+
const prefixEnd = segments[nodeModulesIndex - 1] === "lib"
|
|
70
|
+
? nodeModulesIndex - 1
|
|
71
|
+
: nodeModulesIndex;
|
|
72
|
+
const prefix = segments.slice(0, prefixEnd).join("/");
|
|
73
|
+
if (prefix === "" && normalized.startsWith("/"))
|
|
74
|
+
return "/";
|
|
75
|
+
return prefix || null;
|
|
76
|
+
}
|
|
77
|
+
export function resolveRunningPrefix() {
|
|
78
|
+
try {
|
|
79
|
+
const pkgRoot = findRunningPackageRoot();
|
|
80
|
+
if (!pkgRoot)
|
|
81
|
+
return null;
|
|
82
|
+
return npmPrefixFromPackageDir(pkgRoot);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function buildPrefixedInstallArgv(prefix) {
|
|
89
|
+
return ["install", "-g", "--prefix", prefix, LATEST_PACKAGE_SPEC];
|
|
90
|
+
}
|
|
42
91
|
/**
|
|
43
92
|
* Hit POST /v1/client-version/check. Returns the parsed body on 200, or
|
|
44
93
|
* `null` on any failure (caller treats as "no gate"). Tight 3s timeout —
|
|
@@ -74,18 +123,7 @@ async function fetchVersionDecision() {
|
|
|
74
123
|
return null;
|
|
75
124
|
}
|
|
76
125
|
}
|
|
77
|
-
|
|
78
|
-
* Run the upgrade command in a blocking subprocess. Inherits stdio so the
|
|
79
|
-
* user sees the npm progress. We do NOT auto-rerun the CLI on completion —
|
|
80
|
-
* forcing a re-invocation would run twice on the same process and feel
|
|
81
|
-
* janky; instead we print a clear "rerun your command" message and exit.
|
|
82
|
-
*/
|
|
83
|
-
function performUpdate(command) {
|
|
84
|
-
const parts = command.split(/\s+/).filter(Boolean);
|
|
85
|
-
if (parts.length === 0)
|
|
86
|
-
return { ok: false, detail: "empty command" };
|
|
87
|
-
const cmd = parts[0];
|
|
88
|
-
const args = parts.slice(1);
|
|
126
|
+
function runUpdateCommand(cmd, args) {
|
|
89
127
|
try {
|
|
90
128
|
const result = spawnSync(cmd, args, { stdio: "inherit" });
|
|
91
129
|
if (result.status !== 0) {
|
|
@@ -100,6 +138,17 @@ function performUpdate(command) {
|
|
|
100
138
|
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
|
101
139
|
}
|
|
102
140
|
}
|
|
141
|
+
function performUpdateCommand(cmd, args, runner = runUpdateCommand) {
|
|
142
|
+
return runner(cmd, args);
|
|
143
|
+
}
|
|
144
|
+
function performUpdate(command, runner = runUpdateCommand) {
|
|
145
|
+
const parts = command.split(/\s+/).filter(Boolean);
|
|
146
|
+
if (parts.length === 0)
|
|
147
|
+
return { ok: false, detail: "empty command" };
|
|
148
|
+
const cmd = parts[0];
|
|
149
|
+
const args = parts.slice(1);
|
|
150
|
+
return performUpdateCommand(cmd, args, runner);
|
|
151
|
+
}
|
|
103
152
|
/**
|
|
104
153
|
* Soft notify when the server says we're below `latestVersion` but still ≥
|
|
105
154
|
* `minVersion`. Single chalk-yellow line on stderr; never blocks.
|
|
@@ -121,24 +170,40 @@ function nudgeUpdateRecommended(decision) {
|
|
|
121
170
|
* 0 — update succeeded; user must rerun their command
|
|
122
171
|
* 75 — update failed (EX_TEMPFAIL; common for sudo/EACCES on system npm)
|
|
123
172
|
*/
|
|
124
|
-
function enforceUpdateRequired(decision) {
|
|
173
|
+
function enforceUpdateRequired(decision, deps = {}) {
|
|
125
174
|
const banner = chalk.red.bold(`✗ hq-cli ${decision.currentVersion} is below the minimum required version (${decision.minVersion}).`);
|
|
126
175
|
console.error(banner);
|
|
127
176
|
if (decision.message)
|
|
128
177
|
console.error(chalk.dim(` ${decision.message}`));
|
|
129
178
|
const command = decision.updateCommand;
|
|
130
|
-
|
|
179
|
+
const prefix = (deps.resolvePrefix ?? resolveRunningPrefix)();
|
|
180
|
+
if (!command && !prefix) {
|
|
131
181
|
console.error(chalk.red(" No updateCommand provided by hq-pro — see https://hq.indigo.ai/docs/cli-update for manual steps."));
|
|
132
182
|
if (decision.downloadUrl) {
|
|
133
183
|
console.error(chalk.dim(` Download: ${decision.downloadUrl}`));
|
|
134
184
|
}
|
|
135
185
|
process.exit(75);
|
|
136
186
|
}
|
|
137
|
-
|
|
138
|
-
const result =
|
|
187
|
+
const runner = deps.runner ?? runUpdateCommand;
|
|
188
|
+
const result = prefix
|
|
189
|
+
? (() => {
|
|
190
|
+
const args = buildPrefixedInstallArgv(prefix);
|
|
191
|
+
console.error(chalk.dim(` Installing into npm prefix: ${prefix}`));
|
|
192
|
+
console.error(chalk.dim(` Running: npm ${args.join(" ")}`));
|
|
193
|
+
return performUpdateCommand("npm", args, runner);
|
|
194
|
+
})()
|
|
195
|
+
: (() => {
|
|
196
|
+
console.error(chalk.dim(` Running: ${command}`));
|
|
197
|
+
return deps.performUpdateString
|
|
198
|
+
? deps.performUpdateString(command)
|
|
199
|
+
: performUpdate(command, runner);
|
|
200
|
+
})();
|
|
139
201
|
if (!result.ok) {
|
|
140
202
|
console.error(chalk.red(`✗ Update failed${result.detail ? `: ${result.detail}` : ""}.`));
|
|
141
|
-
|
|
203
|
+
const manual = prefix
|
|
204
|
+
? `npm ${buildPrefixedInstallArgv(prefix).join(" ")}`
|
|
205
|
+
: command;
|
|
206
|
+
console.error(chalk.dim(` Try manually: ${manual}`));
|
|
142
207
|
process.exit(75);
|
|
143
208
|
}
|
|
144
209
|
console.error(chalk.green(`✓ Updated to hq-cli ${decision.latestVersion}. Rerun your command.`));
|
|
@@ -179,7 +244,13 @@ export const __test__ = {
|
|
|
179
244
|
CLIENT_ID,
|
|
180
245
|
ENDPOINT_PATH,
|
|
181
246
|
FETCH_TIMEOUT_MS,
|
|
247
|
+
buildPrefixedInstallArgv,
|
|
248
|
+
enforceUpdateRequired,
|
|
249
|
+
npmPrefixFromPackageDir,
|
|
182
250
|
performUpdate,
|
|
251
|
+
performUpdateCommand,
|
|
252
|
+
runUpdateCommand,
|
|
253
|
+
resolveRunningPrefix,
|
|
183
254
|
};
|
|
184
255
|
//# sourceMappingURL=version-gate.js.map
|
|
185
|
-
//# debugId=
|
|
256
|
+
//# debugId=0f613895-ad4a-5211-bf75-a2e843782c47
|
package/e2e/cli.test.ts
CHANGED
|
@@ -41,6 +41,31 @@ function runHq(args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv
|
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Spawn the built CLI with its stdout read-end closed immediately, so the
|
|
45
|
+
// child's first write to stdout hits EPIPE — the `hq … | head` / `source <(hq
|
|
46
|
+
// …)` scenario behind HQ-6B. Returns the child's own exit code + stderr.
|
|
47
|
+
function runHqWithClosedStdout(args: string[]) {
|
|
48
|
+
return new Promise<{ code: number | null; stderr: string }>(
|
|
49
|
+
(resolve, reject) => {
|
|
50
|
+
const child = spawn(process.execPath, [cliEntry, ...args], {
|
|
51
|
+
env: { ...process.env, HQ_NO_UPDATE_CHECK: "1" },
|
|
52
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
53
|
+
});
|
|
54
|
+
let stderr = "";
|
|
55
|
+
child.stderr.setEncoding("utf8");
|
|
56
|
+
child.stderr.on("data", (chunk) => {
|
|
57
|
+
stderr += chunk;
|
|
58
|
+
});
|
|
59
|
+
// Close the read end up front (and again on any byte that slips through)
|
|
60
|
+
// so subsequent writes by the child fail with EPIPE.
|
|
61
|
+
child.stdout.on("data", () => child.stdout.destroy());
|
|
62
|
+
child.stdout.destroy();
|
|
63
|
+
child.on("error", reject);
|
|
64
|
+
child.on("close", (code) => resolve({ code, stderr }));
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
44
69
|
async function makeTempDir(prefix: string) {
|
|
45
70
|
const dir = await mkdtemp(path.join(tmpdir(), prefix));
|
|
46
71
|
tempDirs.push(dir);
|
|
@@ -62,6 +87,16 @@ describe("built hq CLI", () => {
|
|
|
62
87
|
expect(result.stdout).toContain("whoami");
|
|
63
88
|
});
|
|
64
89
|
|
|
90
|
+
it("exits cleanly when the downstream reader closes the pipe (HQ-6B)", async () => {
|
|
91
|
+
// Regression for HQ-6B: a closed stdout reader must NOT crash the CLI with
|
|
92
|
+
// a fatal `write EPIPE`. The process should exit 0 with no EPIPE traceback.
|
|
93
|
+
const { code, stderr } = await runHqWithClosedStdout(["--help"]);
|
|
94
|
+
|
|
95
|
+
expect(code).toBe(0);
|
|
96
|
+
expect(stderr).not.toMatch(/EPIPE/);
|
|
97
|
+
expect(stderr).not.toMatch(/Error:/);
|
|
98
|
+
});
|
|
99
|
+
|
|
65
100
|
it("prints the package version from the built entrypoint", async () => {
|
|
66
101
|
const result = await runHq(["--version"]);
|
|
67
102
|
|
package/package.json
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* non-interactively.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
// MUST be first: guard the Node version before any dependency that needs a
|
|
16
|
+
// Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
|
|
17
|
+
import "../node-preflight.js";
|
|
15
18
|
import { initSentry, Sentry } from "../sentry.js";
|
|
16
19
|
import { refreshCachedSession } from "../utils/cognito-session.js";
|
|
17
20
|
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
formatInviteHttpError,
|
|
39
39
|
getCallerPersonUid,
|
|
40
40
|
inviteMember,
|
|
41
|
+
listActiveMembers,
|
|
41
42
|
listPendingInvites,
|
|
42
43
|
registerMembersCommand,
|
|
43
44
|
resendInvite,
|
|
@@ -743,6 +744,181 @@ describe("listPendingInvites", () => {
|
|
|
743
744
|
});
|
|
744
745
|
});
|
|
745
746
|
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
// listActiveMembers
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
|
|
751
|
+
describe("listActiveMembers", () => {
|
|
752
|
+
it("GETs /membership/company/{uid} and returns the members array", async () => {
|
|
753
|
+
fetchSpy.mockResolvedValueOnce(
|
|
754
|
+
jsonResponse(200, {
|
|
755
|
+
members: [
|
|
756
|
+
{
|
|
757
|
+
membershipKey: "k1",
|
|
758
|
+
personUid: "prs_alice",
|
|
759
|
+
companyUid: "cmp_acme",
|
|
760
|
+
role: "owner",
|
|
761
|
+
status: "active",
|
|
762
|
+
personEmail: "alice@example.com",
|
|
763
|
+
personName: "Alice",
|
|
764
|
+
},
|
|
765
|
+
],
|
|
766
|
+
}),
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
const members = await listActiveMembers("test-token", "cmp_acme");
|
|
770
|
+
|
|
771
|
+
const call = fetchSpy.mock.calls[0];
|
|
772
|
+
expect(String(call[0])).toMatch(/\/membership\/company\/cmp_acme$/);
|
|
773
|
+
expect(members).toHaveLength(1);
|
|
774
|
+
expect(members[0].personEmail).toBe("alice@example.com");
|
|
775
|
+
expect(members[0].role).toBe("owner");
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("returns [] when the members key is missing", async () => {
|
|
779
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
|
|
780
|
+
await expect(
|
|
781
|
+
listActiveMembers("test-token", "cmp_acme"),
|
|
782
|
+
).resolves.toEqual([]);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it("returns [] when members is null", async () => {
|
|
786
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { members: null }));
|
|
787
|
+
await expect(
|
|
788
|
+
listActiveMembers("test-token", "cmp_acme"),
|
|
789
|
+
).resolves.toEqual([]);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("throws InviteHttpError on non-ok", async () => {
|
|
793
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(403, { error: "forbidden" }));
|
|
794
|
+
await expect(
|
|
795
|
+
listActiveMembers("test-token", "cmp_acme"),
|
|
796
|
+
).rejects.toBeInstanceOf(InviteHttpError);
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
// registerMembersCommand list
|
|
802
|
+
// ---------------------------------------------------------------------------
|
|
803
|
+
|
|
804
|
+
describe("registerMembersCommand list", () => {
|
|
805
|
+
it("defaults to ACTIVE members: renders EMAIL/ROLE/NAME + the share hint", async () => {
|
|
806
|
+
fetchSpy.mockResolvedValueOnce(
|
|
807
|
+
jsonResponse(200, {
|
|
808
|
+
members: [
|
|
809
|
+
{
|
|
810
|
+
membershipKey: "k1",
|
|
811
|
+
personUid: "prs_alice",
|
|
812
|
+
companyUid: "cmp_acme",
|
|
813
|
+
role: "owner",
|
|
814
|
+
status: "active",
|
|
815
|
+
personEmail: "alice@example.com",
|
|
816
|
+
personName: "Alice",
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
membershipKey: "k2",
|
|
820
|
+
personUid: "prs_bob",
|
|
821
|
+
companyUid: "cmp_acme",
|
|
822
|
+
role: "member",
|
|
823
|
+
status: "active",
|
|
824
|
+
personSlug: "bob",
|
|
825
|
+
},
|
|
826
|
+
],
|
|
827
|
+
}),
|
|
828
|
+
);
|
|
829
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
830
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
831
|
+
|
|
832
|
+
await buildMembersProgram().parseAsync(
|
|
833
|
+
["members", "--company", "acme", "list"],
|
|
834
|
+
{ from: "user" },
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
const call = fetchSpy.mock.calls[0];
|
|
838
|
+
expect(String(call[0])).toMatch(/\/membership\/company\/cmp_acme$/);
|
|
839
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
840
|
+
expect(output).toContain("EMAIL");
|
|
841
|
+
expect(output).toContain("ROLE");
|
|
842
|
+
expect(output).toContain("NAME");
|
|
843
|
+
expect(output).toContain("alice@example.com");
|
|
844
|
+
expect(output).toContain("owner");
|
|
845
|
+
expect(output).toContain("Alice");
|
|
846
|
+
// unresolved email falls back to personUid; name falls back to slug
|
|
847
|
+
expect(output).toContain("prs_bob");
|
|
848
|
+
expect(output).toContain("bob");
|
|
849
|
+
expect(output).toContain(
|
|
850
|
+
"hq secrets share <path> --with <email>",
|
|
851
|
+
);
|
|
852
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
it("default active: empty roster prints the no-active-members message", async () => {
|
|
856
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { members: [] }));
|
|
857
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
858
|
+
|
|
859
|
+
await buildMembersProgram().parseAsync(
|
|
860
|
+
["members", "--company", "acme", "list"],
|
|
861
|
+
{ from: "user" },
|
|
862
|
+
);
|
|
863
|
+
|
|
864
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
865
|
+
expect.stringContaining("No active members found for this company."),
|
|
866
|
+
);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("--pending preserves the OLD pending-invites table verbatim", async () => {
|
|
870
|
+
fetchSpy.mockResolvedValueOnce(
|
|
871
|
+
jsonResponse(200, {
|
|
872
|
+
pending: [
|
|
873
|
+
{
|
|
874
|
+
membershipKey: "email:alice@example.com#cmp_acme",
|
|
875
|
+
inviteeEmail: "alice@example.com",
|
|
876
|
+
companyUid: "cmp_acme",
|
|
877
|
+
role: "member",
|
|
878
|
+
status: "pending",
|
|
879
|
+
invitedBy: "prs_admin",
|
|
880
|
+
invitedAt: "2026-05-21T12:00:00Z",
|
|
881
|
+
},
|
|
882
|
+
],
|
|
883
|
+
}),
|
|
884
|
+
);
|
|
885
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
886
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
887
|
+
|
|
888
|
+
await buildMembersProgram().parseAsync(
|
|
889
|
+
["members", "--company", "acme", "list", "--pending"],
|
|
890
|
+
{ from: "user" },
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
const call = fetchSpy.mock.calls[0];
|
|
894
|
+
expect(String(call[0])).toMatch(
|
|
895
|
+
/\/membership\/company\/cmp_acme\/pending$/,
|
|
896
|
+
);
|
|
897
|
+
const output = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
898
|
+
expect(output).toContain("TARGET");
|
|
899
|
+
expect(output).toContain("INVITED_BY");
|
|
900
|
+
expect(output).toContain("MEMBERSHIP_KEY");
|
|
901
|
+
expect(output).toContain("alice@example.com");
|
|
902
|
+
// the active-only share hint must NOT appear in the pending view
|
|
903
|
+
expect(output).not.toContain("hq secrets share");
|
|
904
|
+
expect(errSpy).not.toHaveBeenCalled();
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it("--pending: empty invites prints the no-pending-invites message", async () => {
|
|
908
|
+
fetchSpy.mockResolvedValueOnce(jsonResponse(200, { pending: [] }));
|
|
909
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
910
|
+
|
|
911
|
+
await buildMembersProgram().parseAsync(
|
|
912
|
+
["members", "--company", "acme", "list", "--pending"],
|
|
913
|
+
{ from: "user" },
|
|
914
|
+
);
|
|
915
|
+
|
|
916
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
917
|
+
expect.stringContaining("No pending invites for this company."),
|
|
918
|
+
);
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
|
|
746
922
|
// ---------------------------------------------------------------------------
|
|
747
923
|
// revokeInvite
|
|
748
924
|
// ---------------------------------------------------------------------------
|
package/src/commands/members.ts
CHANGED
|
@@ -31,6 +31,23 @@ interface MyMembership {
|
|
|
31
31
|
status: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* An ACTIVE member of a company, as returned by
|
|
36
|
+
* `GET /membership/company/{companyUid}`. The server filters to
|
|
37
|
+
* `status: "active"` and enriches each row with resolved person metadata
|
|
38
|
+
* (`personEmail` / `personName` / `personSlug`) when available.
|
|
39
|
+
*/
|
|
40
|
+
export interface ActiveMember {
|
|
41
|
+
membershipKey: string;
|
|
42
|
+
personUid: string;
|
|
43
|
+
companyUid: string;
|
|
44
|
+
role: string;
|
|
45
|
+
status: string;
|
|
46
|
+
personEmail?: string;
|
|
47
|
+
personName?: string;
|
|
48
|
+
personSlug?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
export interface InviteOptions {
|
|
35
52
|
target: string;
|
|
36
53
|
role: string;
|
|
@@ -374,6 +391,30 @@ export async function listPendingInvites(
|
|
|
374
391
|
return data?.pending ?? data?.invites ?? [];
|
|
375
392
|
}
|
|
376
393
|
|
|
394
|
+
export async function listActiveMembers(
|
|
395
|
+
token: string,
|
|
396
|
+
companyUid: string,
|
|
397
|
+
): Promise<ActiveMember[]> {
|
|
398
|
+
const res = await vaultApiFetch({
|
|
399
|
+
token,
|
|
400
|
+
path: `/membership/company/${encodeURIComponent(companyUid)}`,
|
|
401
|
+
});
|
|
402
|
+
if (!res.ok) {
|
|
403
|
+
const err = (await res.json().catch(() => ({}))) as Record<string, string>;
|
|
404
|
+
throw new InviteHttpError(
|
|
405
|
+
res.status,
|
|
406
|
+
err.message ?? err.error ?? res.statusText,
|
|
407
|
+
err.code,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
// Server schema: `{ members: [...] }` — active members only, enriched with
|
|
411
|
+
// resolved person metadata (personEmail / personName / personSlug).
|
|
412
|
+
const data = (await res.json()) as {
|
|
413
|
+
members?: ActiveMember[] | null;
|
|
414
|
+
};
|
|
415
|
+
return data?.members ?? [];
|
|
416
|
+
}
|
|
417
|
+
|
|
377
418
|
/**
|
|
378
419
|
* Resolve a `revoke` CLI argument into the canonical `membershipKey` shape
|
|
379
420
|
* the server requires. Accepts three input forms:
|
|
@@ -746,55 +787,99 @@ export function registerMembersCommand(program: Command): void {
|
|
|
746
787
|
|
|
747
788
|
members
|
|
748
789
|
.command("list")
|
|
749
|
-
.description(
|
|
750
|
-
|
|
790
|
+
.description(
|
|
791
|
+
"List the company's active members (use --pending for pending invites)",
|
|
792
|
+
)
|
|
793
|
+
.option("--pending", "List pending invites instead of active members")
|
|
794
|
+
.action(async (opts: { pending?: boolean }) => {
|
|
751
795
|
try {
|
|
752
796
|
const token = await ensureCognitoToken();
|
|
753
797
|
const companySlug = members.opts().company as string | undefined;
|
|
754
798
|
const companyUid = await getCompanyUid(token, companySlug);
|
|
755
799
|
|
|
756
|
-
|
|
800
|
+
if (opts.pending) {
|
|
801
|
+
// --pending: preserve the original pending-invites view verbatim.
|
|
802
|
+
const invites = await listPendingInvites(token, companyUid);
|
|
803
|
+
|
|
804
|
+
if (invites.length === 0) {
|
|
805
|
+
console.log(chalk.gray("No pending invites for this company."));
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
757
808
|
|
|
758
|
-
|
|
759
|
-
|
|
809
|
+
const targetW = Math.max(
|
|
810
|
+
6,
|
|
811
|
+
...invites.map((i) => (i.inviteeEmail ?? i.personUid ?? "").length),
|
|
812
|
+
);
|
|
813
|
+
const roleW = Math.max(4, ...invites.map((i) => i.role.length));
|
|
814
|
+
const byW = Math.max(10, ...invites.map((i) => i.invitedBy.length));
|
|
815
|
+
const keyW = Math.max(
|
|
816
|
+
14,
|
|
817
|
+
...invites.map((i) => i.membershipKey.length),
|
|
818
|
+
);
|
|
819
|
+
console.log(
|
|
820
|
+
chalk.bold(
|
|
821
|
+
[
|
|
822
|
+
"TARGET".padEnd(targetW),
|
|
823
|
+
"ROLE".padEnd(roleW),
|
|
824
|
+
"INVITED_BY".padEnd(byW),
|
|
825
|
+
"INVITED_AT",
|
|
826
|
+
"MEMBERSHIP_KEY".padEnd(keyW),
|
|
827
|
+
].join(" "),
|
|
828
|
+
),
|
|
829
|
+
);
|
|
830
|
+
for (const inv of invites) {
|
|
831
|
+
const target = inv.inviteeEmail ?? inv.personUid ?? "";
|
|
832
|
+
console.log(
|
|
833
|
+
[
|
|
834
|
+
target.padEnd(targetW),
|
|
835
|
+
inv.role.padEnd(roleW),
|
|
836
|
+
inv.invitedBy.padEnd(byW),
|
|
837
|
+
shortDate(inv.invitedAt),
|
|
838
|
+
inv.membershipKey.padEnd(keyW),
|
|
839
|
+
].join(" "),
|
|
840
|
+
);
|
|
841
|
+
}
|
|
760
842
|
return;
|
|
761
843
|
}
|
|
762
844
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
845
|
+
// Default: list ACTIVE members so their emails drop straight into
|
|
846
|
+
// `hq secrets share <path> --with <email>`.
|
|
847
|
+
const activeMembers = await listActiveMembers(token, companyUid);
|
|
848
|
+
|
|
849
|
+
if (activeMembers.length === 0) {
|
|
850
|
+
console.log(chalk.gray("No active members found for this company."));
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const emailW = Math.max(
|
|
855
|
+
5,
|
|
856
|
+
...activeMembers.map((m) => (m.personEmail ?? m.personUid).length),
|
|
766
857
|
);
|
|
767
|
-
const roleW = Math.max(4, ...
|
|
768
|
-
const byW = Math.max(10, ...invites.map((i) => i.invitedBy.length));
|
|
769
|
-
const keyW = Math.max(14, ...invites.map((i) => i.membershipKey.length));
|
|
858
|
+
const roleW = Math.max(4, ...activeMembers.map((m) => m.role.length));
|
|
770
859
|
console.log(
|
|
771
860
|
chalk.bold(
|
|
772
|
-
[
|
|
773
|
-
"TARGET".padEnd(targetW),
|
|
774
|
-
"ROLE".padEnd(roleW),
|
|
775
|
-
"INVITED_BY".padEnd(byW),
|
|
776
|
-
"INVITED_AT",
|
|
777
|
-
"MEMBERSHIP_KEY".padEnd(keyW),
|
|
778
|
-
].join(" "),
|
|
861
|
+
["EMAIL".padEnd(emailW), "ROLE".padEnd(roleW), "NAME"].join(" "),
|
|
779
862
|
),
|
|
780
863
|
);
|
|
781
|
-
for (const
|
|
782
|
-
const
|
|
864
|
+
for (const m of activeMembers) {
|
|
865
|
+
const email = m.personEmail ?? m.personUid;
|
|
866
|
+
const name = m.personName ?? m.personSlug ?? "";
|
|
783
867
|
console.log(
|
|
784
|
-
[
|
|
785
|
-
target.padEnd(targetW),
|
|
786
|
-
inv.role.padEnd(roleW),
|
|
787
|
-
inv.invitedBy.padEnd(byW),
|
|
788
|
-
shortDate(inv.invitedAt),
|
|
789
|
-
inv.membershipKey.padEnd(keyW),
|
|
790
|
-
].join(" "),
|
|
868
|
+
[email.padEnd(emailW), m.role.padEnd(roleW), name].join(" "),
|
|
791
869
|
);
|
|
792
870
|
}
|
|
871
|
+
console.log(
|
|
872
|
+
chalk.gray(
|
|
873
|
+
"Share a secret with a member: hq secrets share <path> --with <email>",
|
|
874
|
+
),
|
|
875
|
+
);
|
|
793
876
|
} catch (err) {
|
|
794
877
|
if (err instanceof InviteHttpError) {
|
|
795
878
|
const msg =
|
|
796
879
|
err.status === 403
|
|
797
|
-
?
|
|
880
|
+
? opts.pending
|
|
881
|
+
? "Not authorized — only admins and owners can list invites"
|
|
882
|
+
: "Not authorized — only company members can list members"
|
|
798
883
|
: formatInviteHttpError(err.status, err.message);
|
|
799
884
|
console.error(chalk.red(msg));
|
|
800
885
|
process.exit(1);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { buildCreateCompanyWarning } from "./onboard-warning.js";
|
|
4
|
+
|
|
5
|
+
describe("buildCreateCompanyWarning", () => {
|
|
6
|
+
it("documents the warning shown on the create-company path", () => {
|
|
7
|
+
const warning = buildCreateCompanyWarning();
|
|
8
|
+
expect(warning).toContain("NEW company");
|
|
9
|
+
expect(warning).toMatch(/does NOT join an existing one/i);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("advises existing-company users to ask an admin or owner for an invite", () => {
|
|
13
|
+
const warning = buildCreateCompanyWarning();
|
|
14
|
+
expect(warning).toMatch(/ask your admin or owner.*invite/i);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("tells invitees to run hq sync after accepting", () => {
|
|
18
|
+
const warning = buildCreateCompanyWarning();
|
|
19
|
+
expect(warning).toContain("accept it and run `hq sync`");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("warns that duplicate creation leaves the user alone in a separate company", () => {
|
|
23
|
+
const warning = buildCreateCompanyWarning();
|
|
24
|
+
expect(warning).toMatch(/separate one you'd be alone in/i);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the static create-time warning surfaced by both `create-company` and
|
|
3
|
+
* `dry-run`, because users may accidentally create duplicate companies instead
|
|
4
|
+
* of joining the existing company they were invited to.
|
|
5
|
+
*/
|
|
6
|
+
export function buildCreateCompanyWarning(): string {
|
|
7
|
+
return (
|
|
8
|
+
" This creates a NEW company that you own — it does NOT join an existing one.\n" +
|
|
9
|
+
" If your company already uses HQ, do NOT create it again: ask your admin or owner to send you an invite, then accept it and run `hq sync` to pull the existing company.\n" +
|
|
10
|
+
" Creating a same-named company makes a separate one you'd be alone in.\n"
|
|
11
|
+
);
|
|
12
|
+
}
|
package/src/commands/onboard.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import { createDefaultVaultClient } from "./cloud-provision.js";
|
|
33
33
|
import { detectOnboardingIdentityMismatch } from "./onboard-identity-guard.js";
|
|
34
34
|
import { planOnboardJoin } from "./onboard-join.js";
|
|
35
|
+
import { buildCreateCompanyWarning } from "./onboard-warning.js";
|
|
35
36
|
|
|
36
37
|
// ---------------------------------------------------------------------------
|
|
37
38
|
// Command registration
|
|
@@ -70,13 +71,7 @@ export function registerOnboardCommand(program: Command): void {
|
|
|
70
71
|
console.log(` Company: ${options.name} (${options.slug})`);
|
|
71
72
|
console.log(` Person: ${options.personName} <${options.email}>`);
|
|
72
73
|
console.log(` HQ root: ${options.hqRoot}\n`);
|
|
73
|
-
console.log(
|
|
74
|
-
chalk.gray(
|
|
75
|
-
" This creates a NEW company you own. Joining a teammate's existing\n" +
|
|
76
|
-
" company? Stop — accept your invite, then run `hq sync` to pull it.\n" +
|
|
77
|
-
" Creating a same-named company leaves you alone in a separate one.\n",
|
|
78
|
-
),
|
|
79
|
-
);
|
|
74
|
+
console.log(chalk.gray(buildCreateCompanyWarning()));
|
|
80
75
|
|
|
81
76
|
const accessToken = await ensureCognitoToken();
|
|
82
77
|
const result = await runOnboardCli({
|
|
@@ -248,6 +243,8 @@ export function registerOnboardCommand(program: Command): void {
|
|
|
248
243
|
)
|
|
249
244
|
.action(async (options: { hqRoot: string }) => {
|
|
250
245
|
try {
|
|
246
|
+
console.log(chalk.gray(buildCreateCompanyWarning()));
|
|
247
|
+
|
|
251
248
|
// No auth needed for dry-run — runOnboardCli handles this branch
|
|
252
249
|
// without touching the vault-service.
|
|
253
250
|
const result = await runOnboardCli({
|