@indigoai-us/hq-cli 5.47.16 → 5.48.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/.github/workflows/ci.yml +16 -20
- package/.github/workflows/publish.yml +26 -101
- package/dist/commands/feedback.d.ts +2 -0
- package/dist/commands/feedback.js +24 -2
- package/dist/commands/files.d.ts +19 -0
- package/dist/commands/files.js +37 -3
- package/dist/commands/meetings.js +50 -2
- package/dist/commands/secrets.js +25 -2
- package/dist/run/hq-plugin.js +9 -2
- package/dist/sentry-dsn.generated.d.ts +1 -1
- package/dist/sentry-dsn.generated.js +1 -1
- package/dist/sentry.js +7 -3
- package/dist/utils/feedback-diagnostics.d.ts +7 -0
- package/dist/utils/feedback-diagnostics.js +4 -2
- package/dist/utils/feedback-screenshots.d.ts +23 -0
- package/dist/utils/feedback-screenshots.js +98 -0
- package/dist/utils/feedback-versions.d.ts +34 -0
- package/dist/utils/feedback-versions.js +50 -0
- package/package.json +4 -2
- package/src/commands/feedback.test.ts +44 -0
- package/src/commands/feedback.ts +46 -13
- package/src/commands/files-delete.test.ts +132 -0
- package/src/commands/files.ts +42 -1
- package/src/commands/meetings.test.ts +163 -0
- package/src/commands/meetings.ts +60 -0
- package/src/commands/secrets.test.ts +80 -0
- package/src/commands/secrets.ts +35 -0
- package/src/run/hq-plugin.test.ts +39 -0
- package/src/run/hq-plugin.ts +7 -0
- package/src/sentry.ts +5 -1
- package/src/utils/feedback-diagnostics.test.ts +11 -0
- package/src/utils/feedback-diagnostics.ts +8 -0
- package/src/utils/feedback-screenshots.test.ts +134 -0
- package/src/utils/feedback-screenshots.ts +124 -0
- package/src/utils/feedback-versions.test.ts +98 -0
- package/src/utils/feedback-versions.ts +68 -0
- package/test/helpers/vault-service-mock.ts +6 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
|
|
2
|
+
!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]="13f46978-c3c8-532d-b0bb-e8febf876587")}catch(e){}}();
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { vaultApiFetch } from "./vault-api.js";
|
|
6
|
+
export const MAX_SCREENSHOTS = 5;
|
|
7
|
+
// Per-image ceiling. Screenshots are PNG/JPEG captures; 10 MB is generous.
|
|
8
|
+
export const MAX_SCREENSHOT_BYTES = 10 * 1024 * 1024;
|
|
9
|
+
// Extension → content type. Must stay in sync with the server's allowed set
|
|
10
|
+
// (hq-pro feedback-screenshots.ts ALLOWED_CONTENT_TYPES).
|
|
11
|
+
const EXT_CONTENT_TYPE = {
|
|
12
|
+
".png": "image/png",
|
|
13
|
+
".jpg": "image/jpeg",
|
|
14
|
+
".jpeg": "image/jpeg",
|
|
15
|
+
".webp": "image/webp",
|
|
16
|
+
".gif": "image/gif",
|
|
17
|
+
};
|
|
18
|
+
export function contentTypeForPath(filePath) {
|
|
19
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
20
|
+
const contentType = EXT_CONTENT_TYPE[ext];
|
|
21
|
+
if (!contentType) {
|
|
22
|
+
throw new Error(`unsupported screenshot type: ${filePath} (allowed: ${Object.keys(EXT_CONTENT_TYPE).join(", ")})`);
|
|
23
|
+
}
|
|
24
|
+
return contentType;
|
|
25
|
+
}
|
|
26
|
+
/** Validate + read the given screenshot paths (count, type, existence, size). */
|
|
27
|
+
export function loadScreenshots(paths) {
|
|
28
|
+
if (paths.length > MAX_SCREENSHOTS) {
|
|
29
|
+
throw new Error(`at most ${MAX_SCREENSHOTS} screenshots are allowed (got ${paths.length})`);
|
|
30
|
+
}
|
|
31
|
+
return paths.map((filePath) => {
|
|
32
|
+
const contentType = contentTypeForPath(filePath);
|
|
33
|
+
let bytes;
|
|
34
|
+
try {
|
|
35
|
+
bytes = fs.readFileSync(filePath);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new Error(`cannot read screenshot: ${filePath}`);
|
|
39
|
+
}
|
|
40
|
+
if (bytes.byteLength === 0) {
|
|
41
|
+
throw new Error(`screenshot is empty: ${filePath}`);
|
|
42
|
+
}
|
|
43
|
+
if (bytes.byteLength > MAX_SCREENSHOT_BYTES) {
|
|
44
|
+
throw new Error(`screenshot too large: ${filePath} (${bytes.byteLength} bytes, max ${MAX_SCREENSHOT_BYTES})`);
|
|
45
|
+
}
|
|
46
|
+
return { path: filePath, contentType, bytes };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Validate the screenshot paths, request presigned PUT URLs from the feedback
|
|
51
|
+
* endpoint, upload each image direct to S3, and return the object keys to
|
|
52
|
+
* attach to the feedback submission. Returns [] for no screenshots.
|
|
53
|
+
*
|
|
54
|
+
* `fetchImpl` is injectable for tests; defaults to the global fetch.
|
|
55
|
+
*/
|
|
56
|
+
export async function uploadScreenshots(opts) {
|
|
57
|
+
if (opts.paths.length === 0)
|
|
58
|
+
return [];
|
|
59
|
+
const inputs = loadScreenshots(opts.paths);
|
|
60
|
+
const res = await vaultApiFetch({
|
|
61
|
+
token: opts.token,
|
|
62
|
+
path: "/v1/feedback/screenshots/presign",
|
|
63
|
+
method: "POST",
|
|
64
|
+
body: { contentTypes: inputs.map((i) => i.contentType) },
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const data = await res.json().catch(() => ({}));
|
|
68
|
+
const msg = data &&
|
|
69
|
+
typeof data === "object" &&
|
|
70
|
+
typeof data.error === "string"
|
|
71
|
+
? data.error
|
|
72
|
+
: res.statusText;
|
|
73
|
+
throw new Error(`Failed to presign screenshots: ${msg}`);
|
|
74
|
+
}
|
|
75
|
+
const parsed = (await res.json());
|
|
76
|
+
if (!parsed ||
|
|
77
|
+
!Array.isArray(parsed.screenshots) ||
|
|
78
|
+
parsed.screenshots.length !== inputs.length) {
|
|
79
|
+
throw new Error("Presign response did not match the requested screenshots");
|
|
80
|
+
}
|
|
81
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
82
|
+
const keys = [];
|
|
83
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
84
|
+
const slot = parsed.screenshots[i];
|
|
85
|
+
const put = await doFetch(slot.url, {
|
|
86
|
+
method: "PUT",
|
|
87
|
+
headers: { "Content-Type": slot.contentType },
|
|
88
|
+
body: inputs[i].bytes,
|
|
89
|
+
});
|
|
90
|
+
if (!put.ok) {
|
|
91
|
+
throw new Error(`Failed to upload screenshot ${inputs[i].path}: HTTP ${put.status}`);
|
|
92
|
+
}
|
|
93
|
+
keys.push(slot.key);
|
|
94
|
+
}
|
|
95
|
+
return keys;
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=feedback-screenshots.js.map
|
|
98
|
+
//# debugId=13f46978-c3c8-532d-b0bb-e8febf876587
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three HQ component versions captured from the submitter's environment
|
|
3
|
+
* and attached to a feedback submission so triage can see exactly which
|
|
4
|
+
* versions a report came from.
|
|
5
|
+
*
|
|
6
|
+
* - `cli` — this hq-cli build (always known).
|
|
7
|
+
* - `core` — the HQ scaffold version from `core/core.yaml` (`hqVersion`);
|
|
8
|
+
* null when the command runs outside an HQ tree.
|
|
9
|
+
* - `sync` — the installed hq-sync menubar app version, which the app
|
|
10
|
+
* records at `~/.hq/sync-version.json` on startup; null when
|
|
11
|
+
* hq-sync is not installed (e.g. CLI-only / CI environments).
|
|
12
|
+
*/
|
|
13
|
+
export interface VersionInfo {
|
|
14
|
+
cli: string;
|
|
15
|
+
core: string | null;
|
|
16
|
+
sync: string | null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Best-effort read of the hq-core scaffold version (`core/core.yaml`
|
|
20
|
+
* `hqVersion`). Resolves the HQ root from the working directory; returns
|
|
21
|
+
* null when no HQ root / core.yaml is found rather than throwing — version
|
|
22
|
+
* capture must never break a feedback submission.
|
|
23
|
+
*/
|
|
24
|
+
export declare function readCoreVersion(): string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Best-effort read of the hq-sync menubar app version. The app writes
|
|
27
|
+
* `{ version, updatedAt }` to `~/.hq/sync-version.json` on startup; the CLI
|
|
28
|
+
* reads it here. Returns null when the file is absent or malformed (hq-sync
|
|
29
|
+
* not installed, or an older build that predates the marker).
|
|
30
|
+
*/
|
|
31
|
+
export declare function readSyncVersion(homeDir?: string): string | null;
|
|
32
|
+
/** Collect all three component versions, each independently best-effort. */
|
|
33
|
+
export declare function collectVersions(): VersionInfo;
|
|
34
|
+
//# sourceMappingURL=feedback-versions.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
|
|
2
|
+
!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]="ea3328d1-5c42-5d60-ad24-f96c11a37f22")}catch(e){}}();
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { CLI_VERSION } from "../cli-version.js";
|
|
7
|
+
import { findHqRoot } from "./manifest.js";
|
|
8
|
+
import { readHqVersion } from "./pack-contributions.js";
|
|
9
|
+
/**
|
|
10
|
+
* Best-effort read of the hq-core scaffold version (`core/core.yaml`
|
|
11
|
+
* `hqVersion`). Resolves the HQ root from the working directory; returns
|
|
12
|
+
* null when no HQ root / core.yaml is found rather than throwing — version
|
|
13
|
+
* capture must never break a feedback submission.
|
|
14
|
+
*/
|
|
15
|
+
export function readCoreVersion() {
|
|
16
|
+
try {
|
|
17
|
+
return readHqVersion(findHqRoot());
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Best-effort read of the hq-sync menubar app version. The app writes
|
|
25
|
+
* `{ version, updatedAt }` to `~/.hq/sync-version.json` on startup; the CLI
|
|
26
|
+
* reads it here. Returns null when the file is absent or malformed (hq-sync
|
|
27
|
+
* not installed, or an older build that predates the marker).
|
|
28
|
+
*/
|
|
29
|
+
export function readSyncVersion(homeDir = os.homedir()) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = fs.readFileSync(path.join(homeDir, ".hq", "sync-version.json"), "utf-8");
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
return typeof parsed.version === "string" && parsed.version.length > 0
|
|
34
|
+
? parsed.version
|
|
35
|
+
: null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Collect all three component versions, each independently best-effort. */
|
|
42
|
+
export function collectVersions() {
|
|
43
|
+
return {
|
|
44
|
+
cli: CLI_VERSION,
|
|
45
|
+
core: readCoreVersion(),
|
|
46
|
+
sync: readSyncVersion(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=feedback-versions.js.map
|
|
50
|
+
//# debugId=ea3328d1-5c42-5d60-ad24-f96c11a37f22
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.48.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -11,11 +11,12 @@
|
|
|
11
11
|
"build": "node scripts/generate-dsn.mjs && tsc",
|
|
12
12
|
"typecheck": "tsc --noEmit",
|
|
13
13
|
"test": "vitest run",
|
|
14
|
+
"coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary",
|
|
14
15
|
"vitest": "vitest",
|
|
15
16
|
"clean": "rm -rf dist"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|
|
18
|
-
"@indigoai-us/hq-cloud": "^6.11.
|
|
19
|
+
"@indigoai-us/hq-cloud": "^6.11.14",
|
|
19
20
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
20
21
|
"@sentry/node": "^10.49.0",
|
|
21
22
|
"chalk": "^5.3.0",
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
"@types/js-yaml": "^4.0.9",
|
|
32
33
|
"@types/node": "^22.0.0",
|
|
33
34
|
"@types/semver": "^7.5.8",
|
|
35
|
+
"@vitest/coverage-v8": "4.1.6",
|
|
34
36
|
"typescript": "^5.7.0",
|
|
35
37
|
"vitest": "^4.1.2"
|
|
36
38
|
},
|
|
@@ -156,6 +156,24 @@ describe("submitFeedback", () => {
|
|
|
156
156
|
expect((call.body as Record<string, unknown>)).not.toHaveProperty("company");
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
it("includes screenshot keys when provided, omits the key otherwise", async () => {
|
|
160
|
+
mockVaultApiFetch.mockResolvedValueOnce(jsonResponse(200, { id: "feedback_s1" }));
|
|
161
|
+
await submitFeedback({
|
|
162
|
+
type: "bug",
|
|
163
|
+
title: "With shots",
|
|
164
|
+
body: "Details",
|
|
165
|
+
token: "tok",
|
|
166
|
+
screenshots: ["feedback-screenshots/prs_a/sub/0.png"],
|
|
167
|
+
});
|
|
168
|
+
expect((mockVaultApiFetch.mock.calls[0][0].body as Record<string, unknown>).screenshots).toEqual([
|
|
169
|
+
"feedback-screenshots/prs_a/sub/0.png",
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
mockVaultApiFetch.mockResolvedValueOnce(jsonResponse(200, { id: "feedback_s2" }));
|
|
173
|
+
await submitFeedback({ type: "bug", title: "No shots", body: "Details", token: "tok", screenshots: [] });
|
|
174
|
+
expect(mockVaultApiFetch.mock.calls[1][0].body).not.toHaveProperty("screenshots");
|
|
175
|
+
});
|
|
176
|
+
|
|
159
177
|
it("attaches diagnostics from collectDiagnostics to the request body", async () => {
|
|
160
178
|
mockVaultApiFetch.mockResolvedValueOnce(
|
|
161
179
|
jsonResponse(200, { id: "feedback_diag" }),
|
|
@@ -249,6 +267,32 @@ describe("submitFeedback", () => {
|
|
|
249
267
|
|
|
250
268
|
expect(mockVaultApiFetch).not.toHaveBeenCalled();
|
|
251
269
|
});
|
|
270
|
+
|
|
271
|
+
// HQ-AB: an empty/whitespace title (e.g. `--title ""`, or a title the /hq-bug
|
|
272
|
+
// skill derived to nothing) used to slip past Commander's required-flag check
|
|
273
|
+
// and 400 server-side, flooding Sentry with a context-free warning. The local
|
|
274
|
+
// guard now rejects it before any network call.
|
|
275
|
+
it("throws before fetching when title is empty or whitespace-only", async () => {
|
|
276
|
+
await expect(
|
|
277
|
+
submitFeedback({
|
|
278
|
+
type: "bug",
|
|
279
|
+
title: " \n\t ",
|
|
280
|
+
body: "Real body",
|
|
281
|
+
token: "tok",
|
|
282
|
+
}),
|
|
283
|
+
).rejects.toThrow(/title must not be empty/);
|
|
284
|
+
|
|
285
|
+
await expect(
|
|
286
|
+
submitFeedback({
|
|
287
|
+
type: "feature",
|
|
288
|
+
title: "",
|
|
289
|
+
body: "Real body",
|
|
290
|
+
token: "tok",
|
|
291
|
+
}),
|
|
292
|
+
).rejects.toThrow(/title must not be empty/);
|
|
293
|
+
|
|
294
|
+
expect(mockVaultApiFetch).not.toHaveBeenCalled();
|
|
295
|
+
});
|
|
252
296
|
});
|
|
253
297
|
|
|
254
298
|
// ---------------------------------------------------------------------------
|
package/src/commands/feedback.ts
CHANGED
|
@@ -4,6 +4,7 @@ import chalk from "chalk";
|
|
|
4
4
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
5
5
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
6
6
|
import { collectDiagnostics } from "../utils/feedback-diagnostics.js";
|
|
7
|
+
import { MAX_SCREENSHOTS, uploadScreenshots } from "../utils/feedback-screenshots.js";
|
|
7
8
|
|
|
8
9
|
export const BODY_MAX_BYTES = 64 * 1024;
|
|
9
10
|
|
|
@@ -17,6 +18,8 @@ export interface FeedbackSubmitOptions {
|
|
|
17
18
|
body: string;
|
|
18
19
|
company?: string;
|
|
19
20
|
token: string;
|
|
21
|
+
/** S3 object keys of already-uploaded screenshots (see uploadScreenshots). */
|
|
22
|
+
screenshots?: string[];
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export async function readBodyFile(
|
|
@@ -46,6 +49,19 @@ export async function readBodyFile(
|
|
|
46
49
|
export async function submitFeedback(
|
|
47
50
|
opts: FeedbackSubmitOptions,
|
|
48
51
|
): Promise<FeedbackResult> {
|
|
52
|
+
// Validate the title locally, symmetric with the body check below. Commander's
|
|
53
|
+
// `requiredOption("--title")` only requires the flag to be PRESENT — an empty
|
|
54
|
+
// or whitespace-only value (`--title ""`, or a title the /hq-bug skill derived
|
|
55
|
+
// to nothing) passes the flag check, then the server rejects it with a 400
|
|
56
|
+
// "title (non-empty string) is required" that floods Sentry as a context-free
|
|
57
|
+
// warning (HQ-AB). Catch it here so the caller gets a clear, actionable error
|
|
58
|
+
// and the bad request never reaches the server.
|
|
59
|
+
if (opts.title.trim().length === 0) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"title must not be empty. Provide a short, non-whitespace title via --title.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
if (opts.body.trim().length === 0) {
|
|
50
66
|
throw new Error(
|
|
51
67
|
"body must not be empty. Provide at least one non-whitespace character.",
|
|
@@ -70,6 +86,9 @@ export async function submitFeedback(
|
|
|
70
86
|
if (opts.company) {
|
|
71
87
|
requestBody.company = opts.company;
|
|
72
88
|
}
|
|
89
|
+
if (opts.screenshots && opts.screenshots.length > 0) {
|
|
90
|
+
requestBody.screenshots = opts.screenshots;
|
|
91
|
+
}
|
|
73
92
|
|
|
74
93
|
const res = await vaultApiFetch({
|
|
75
94
|
token: opts.token,
|
|
@@ -104,19 +123,33 @@ function registerSubcommand(feedbackCmd: Command, type: "bug" | "feature"): void
|
|
|
104
123
|
"Path to a markdown file with the body; use - to read from stdin",
|
|
105
124
|
)
|
|
106
125
|
.option("--company <slug>", "Company slug to associate with the report")
|
|
107
|
-
.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
token
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
126
|
+
.option(
|
|
127
|
+
"--screenshot <path>",
|
|
128
|
+
`Attach a screenshot (repeatable, up to ${MAX_SCREENSHOTS}; .png/.jpg/.jpeg/.webp/.gif)`,
|
|
129
|
+
(value: string, prev: string[]) => [...prev, value],
|
|
130
|
+
[] as string[],
|
|
131
|
+
)
|
|
132
|
+
.action(
|
|
133
|
+
async (opts: { title: string; bodyFile: string; company?: string; screenshot: string[] }) => {
|
|
134
|
+
try {
|
|
135
|
+
const token = await ensureCognitoToken({ interactive: false });
|
|
136
|
+
const body = await readBodyFile(opts.bodyFile);
|
|
137
|
+
// Validate + upload screenshots (direct-to-S3 via presigned PUT)
|
|
138
|
+
// before submitting, so the row references uploaded objects.
|
|
139
|
+
const screenshots = await uploadScreenshots({
|
|
140
|
+
paths: opts.screenshot ?? [],
|
|
141
|
+
token,
|
|
142
|
+
});
|
|
143
|
+
const result = await submitFeedback({
|
|
144
|
+
type,
|
|
145
|
+
title: opts.title,
|
|
146
|
+
body,
|
|
147
|
+
company: opts.company,
|
|
148
|
+
token,
|
|
149
|
+
screenshots,
|
|
150
|
+
});
|
|
151
|
+
console.log(`Submitted: ${result.id}`);
|
|
152
|
+
} catch (err) {
|
|
120
153
|
console.error(
|
|
121
154
|
chalk.red("Error:"),
|
|
122
155
|
err instanceof Error ? err.message : String(err),
|
|
@@ -38,6 +38,7 @@ import { Command } from "commander";
|
|
|
38
38
|
import {
|
|
39
39
|
registerFilesCommand,
|
|
40
40
|
runFilesDelete,
|
|
41
|
+
stripRedundantCompanyScope,
|
|
41
42
|
FilesDeleteHttpError,
|
|
42
43
|
formatFilesDeleteError,
|
|
43
44
|
type FilesDeleteResponse,
|
|
@@ -242,6 +243,137 @@ describe("hq files delete — root/empty prefix rejected client-side", () => {
|
|
|
242
243
|
}
|
|
243
244
|
});
|
|
244
245
|
|
|
246
|
+
// HQ-8F: a caller who pastes an HQ *local* tree path (`companies/<slug>/…`)
|
|
247
|
+
// over-prefixes the bucket-relative vault key; the server rejects it with a 400
|
|
248
|
+
// (INVALID_PREFIX_COMPANIES_SCOPED), the source of the recurring warning. The
|
|
249
|
+
// CLI now strips the redundant scope BEFORE sending, so the local-looking path
|
|
250
|
+
// is gracefully normalized to bucket-relative.
|
|
251
|
+
describe("stripRedundantCompanyScope (HQ-8F, pure)", () => {
|
|
252
|
+
it("strips a leading companies/<slug>/ to the bucket-relative remainder", () => {
|
|
253
|
+
expect(stripRedundantCompanyScope("companies/acme/projects/foo")).toEqual({
|
|
254
|
+
prefix: "projects/foo",
|
|
255
|
+
strippedSlug: "acme",
|
|
256
|
+
});
|
|
257
|
+
expect(stripRedundantCompanyScope("companies/acme/projects/foo/*")).toEqual({
|
|
258
|
+
prefix: "projects/foo/*",
|
|
259
|
+
strippedSlug: "acme",
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("returns an EMPTY remainder for the company-root spellings (→ root-reject)", () => {
|
|
264
|
+
expect(stripRedundantCompanyScope("companies/acme")).toEqual({
|
|
265
|
+
prefix: "",
|
|
266
|
+
strippedSlug: "acme",
|
|
267
|
+
});
|
|
268
|
+
expect(stripRedundantCompanyScope("companies/acme/")).toEqual({
|
|
269
|
+
prefix: "",
|
|
270
|
+
strippedSlug: "acme",
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("leaves an already bucket-relative prefix untouched (null)", () => {
|
|
275
|
+
expect(stripRedundantCompanyScope("projects/foo/*")).toBeNull();
|
|
276
|
+
expect(stripRedundantCompanyScope("reports/q3/")).toBeNull();
|
|
277
|
+
// A non-scope path that merely starts with the word "companies" is NOT a
|
|
278
|
+
// scope prefix and must be left alone.
|
|
279
|
+
expect(stripRedundantCompanyScope("companiesreport/x")).toBeNull();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("hq files delete — HQ-8F company-scope normalization", () => {
|
|
284
|
+
it("strips companies/<slug>/ and sends the BUCKET-RELATIVE prefix (no 400)", async () => {
|
|
285
|
+
fetchSpy.mockResolvedValueOnce(membershipResponse());
|
|
286
|
+
fetchSpy.mockResolvedValueOnce(
|
|
287
|
+
deleteResponse({ dryRun: true, matched: 1, keys: ["projects/foo/a.md"] }),
|
|
288
|
+
);
|
|
289
|
+
fetchSpy.mockResolvedValueOnce(
|
|
290
|
+
deleteResponse({ matched: 1, deleted: 1, tombstoned: 1, keys: ["projects/foo/a.md"] }),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
await runFilesDelete(
|
|
294
|
+
{
|
|
295
|
+
// Trailing slash → also exercises the `/` → `/*` glob normalization.
|
|
296
|
+
prefix: "companies/acme/projects/foo/",
|
|
297
|
+
dryRun: false,
|
|
298
|
+
yes: true,
|
|
299
|
+
companySlug: undefined,
|
|
300
|
+
},
|
|
301
|
+
{ confirm: async () => true },
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const bodies = deleteCallBodies();
|
|
305
|
+
// Both the preview and the delete carry the stripped, bucket-relative prefix.
|
|
306
|
+
expect(bodies.map((b) => b.prefix)).toEqual(["projects/foo/*", "projects/foo/*"]);
|
|
307
|
+
expect(printedErr()).toContain("stripped redundant 'companies/acme/'");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("a bare companies/<slug> strips to empty → root-reject, no network call", async () => {
|
|
311
|
+
const program = buildProgram();
|
|
312
|
+
await expect(
|
|
313
|
+
program.parseAsync(["files", "delete", "companies/acme", "--yes"], {
|
|
314
|
+
from: "user",
|
|
315
|
+
}),
|
|
316
|
+
).rejects.toThrow(/__EXIT__:1/);
|
|
317
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
318
|
+
expect(printedErr()).toContain("Refusing to delete the vault root");
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// HQ-CA: the EXACT-KEY sibling of HQ-8F. When the over-prefixed path has no
|
|
323
|
+
// trailing slash and no wildcard (a literal key like
|
|
324
|
+
// `companies/<slug>/notes/foo.md`), the server takes the exact-key branch where
|
|
325
|
+
// `validateObjectKey` — not `validatePrefix` — rejects it with a 400 ("Invalid
|
|
326
|
+
// key: … do not prefix with 'companies/<slug>/'."). The same client-side strip
|
|
327
|
+
// runs UPSTREAM of the server's exact-vs-glob split, so it normalizes the exact
|
|
328
|
+
// key to bucket-relative and the 400 is never emitted. This locks that path,
|
|
329
|
+
// which #117 only exercised via the trailing-slash (glob) spelling.
|
|
330
|
+
describe("hq files delete — HQ-CA exact-key company-scope normalization", () => {
|
|
331
|
+
it("strips companies/<slug>/ from an EXACT key and sends the bucket-relative key (no glob, no 400)", async () => {
|
|
332
|
+
fetchSpy.mockResolvedValueOnce(membershipResponse());
|
|
333
|
+
fetchSpy.mockResolvedValueOnce(
|
|
334
|
+
deleteResponse({
|
|
335
|
+
dryRun: true,
|
|
336
|
+
mode: "exact",
|
|
337
|
+
prefix: "notes/foo.md",
|
|
338
|
+
matched: 1,
|
|
339
|
+
keys: ["notes/foo.md"],
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
342
|
+
fetchSpy.mockResolvedValueOnce(
|
|
343
|
+
deleteResponse({
|
|
344
|
+
mode: "exact",
|
|
345
|
+
prefix: "notes/foo.md",
|
|
346
|
+
matched: 1,
|
|
347
|
+
deleted: 1,
|
|
348
|
+
tombstoned: 1,
|
|
349
|
+
keys: ["notes/foo.md"],
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
await runFilesDelete(
|
|
354
|
+
{
|
|
355
|
+
// No trailing slash, no wildcard → server takes the EXACT-key branch.
|
|
356
|
+
prefix: "companies/acme/notes/foo.md",
|
|
357
|
+
dryRun: false,
|
|
358
|
+
yes: true,
|
|
359
|
+
companySlug: undefined,
|
|
360
|
+
},
|
|
361
|
+
{ confirm: async () => true },
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const bodies = deleteCallBodies();
|
|
365
|
+
// Preview + delete both carry the stripped, bucket-relative EXACT key —
|
|
366
|
+
// never the `companies/acme/...` over-prefix that 400s, and never glob-ified
|
|
367
|
+
// into a prefix delete.
|
|
368
|
+
expect(bodies.map((b) => b.prefix)).toEqual(["notes/foo.md", "notes/foo.md"]);
|
|
369
|
+
for (const b of bodies) {
|
|
370
|
+
expect(b.prefix.startsWith("companies/")).toBe(false);
|
|
371
|
+
expect(b.prefix).not.toContain("*");
|
|
372
|
+
}
|
|
373
|
+
expect(printedErr()).toContain("stripped redundant 'companies/acme/'");
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
245
377
|
describe("hq files delete — server error mapping", () => {
|
|
246
378
|
it("403 surfaces a clear not-authorized message and exits 1", async () => {
|
|
247
379
|
fetchSpy.mockResolvedValueOnce(membershipResponse());
|
package/src/commands/files.ts
CHANGED
|
@@ -782,17 +782,58 @@ function printKeyPreview(resp: FilesDeleteResponse): void {
|
|
|
782
782
|
}
|
|
783
783
|
}
|
|
784
784
|
|
|
785
|
+
/**
|
|
786
|
+
* The vault bucket is already company-scoped, so a delete prefix must be
|
|
787
|
+
* BUCKET-RELATIVE (e.g. `projects/foo/*`). A caller who pastes an HQ *local*
|
|
788
|
+
* tree path (`companies/<slug>/projects/foo`) over-prefixes it; the server then
|
|
789
|
+
* rejects it with INVALID_PREFIX_COMPANIES_SCOPED (HTTP 400), the source of the
|
|
790
|
+
* recurring Sentry warning HQ-8F. Strip a redundant leading `companies/<slug>/`
|
|
791
|
+
* so the local-looking path is normalized to the bucket-relative key the vault
|
|
792
|
+
* actually stores. Returns the stripped slug for a one-line notice, or null when
|
|
793
|
+
* there was nothing to strip. Pure → unit-testable.
|
|
794
|
+
*
|
|
795
|
+
* This runs UPSTREAM of the server's exact-vs-glob branch, so it covers both
|
|
796
|
+
* the glob spelling (`companies/<slug>/projects/foo/*` → `validatePrefix`,
|
|
797
|
+
* HQ-8F) and the EXACT-key spelling (`companies/<slug>/notes/foo.md` →
|
|
798
|
+
* `validateObjectKey`, HQ-CA) with the same normalization.
|
|
799
|
+
*/
|
|
800
|
+
export function stripRedundantCompanyScope(
|
|
801
|
+
prefix: string,
|
|
802
|
+
): { prefix: string; strippedSlug: string } | null {
|
|
803
|
+
const m = /^companies\/([^/]+)(?:\/(.*))?$/.exec(prefix);
|
|
804
|
+
if (!m) return null;
|
|
805
|
+
return { prefix: m[2] ?? "", strippedSlug: m[1] };
|
|
806
|
+
}
|
|
807
|
+
|
|
785
808
|
export async function runFilesDelete(
|
|
786
809
|
params: RunFilesDeleteParams,
|
|
787
810
|
deps: { confirm?: ConfirmFn } = {},
|
|
788
811
|
): Promise<void> {
|
|
789
812
|
const confirm = deps.confirm ?? realConfirm;
|
|
790
813
|
|
|
814
|
+
// The vault is already company-scoped — a `companies/<slug>/` prefix is the HQ
|
|
815
|
+
// LOCAL tree layout, not a vault key, and the server 400s it (HQ-8F). Strip it
|
|
816
|
+
// here so a pasted local path is gracefully normalized to bucket-relative
|
|
817
|
+
// BEFORE the dry-run/preview (so the operator still sees the exact keys and
|
|
818
|
+
// confirms the right target). If stripping empties the prefix, the root-reject
|
|
819
|
+
// below catches it with a clear message.
|
|
820
|
+
const scope = stripRedundantCompanyScope(params.prefix);
|
|
821
|
+
if (scope) {
|
|
822
|
+
console.error(
|
|
823
|
+
chalk.yellow(
|
|
824
|
+
`Note: stripped redundant 'companies/${scope.strippedSlug}/' — the vault ` +
|
|
825
|
+
`is already company-scoped; using bucket-relative ` +
|
|
826
|
+
`'${scope.prefix || "(root)"}'.`,
|
|
827
|
+
),
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
const rawPrefix = scope ? scope.prefix : params.prefix;
|
|
831
|
+
|
|
791
832
|
// Normalize exactly as the share/unshare/acl paths do (trailing `/` → `/*`),
|
|
792
833
|
// then reject the root/empty prefix CLIENT-side so a typo never reaches the
|
|
793
834
|
// server as a vault-wide delete. The server enforces this too (defense in
|
|
794
835
|
// depth), but failing fast here is clearer and avoids a wasted round-trip.
|
|
795
|
-
const normalized = normalizeFilePrefix(
|
|
836
|
+
const normalized = normalizeFilePrefix(rawPrefix);
|
|
796
837
|
if (normalized === "" || normalized === "*" || normalized === "/*") {
|
|
797
838
|
console.error(
|
|
798
839
|
chalk.red(
|