@indigoai-us/hq-cloud 5.41.0 → 5.42.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/sync-runner.d.ts +26 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +90 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +168 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync-scope.test.d.ts +22 -0
- package/dist/cli/sync-scope.test.d.ts.map +1 -0
- package/dist/cli/sync-scope.test.js +273 -0
- package/dist/cli/sync-scope.test.js.map +1 -0
- package/dist/cli/sync.d.ts +64 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +152 -4
- package/dist/cli/sync.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +29 -0
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +48 -0
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +51 -1
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +18 -0
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +28 -0
- package/dist/scope-shrink.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +222 -0
- package/src/bin/sync-runner.ts +108 -0
- package/src/cli/sync-scope.test.ts +307 -0
- package/src/cli/sync.ts +240 -1
- package/src/index.ts +1 -0
- package/src/prefix-coalesce.test.ts +76 -1
- package/src/prefix-coalesce.ts +45 -0
- package/src/scope-shrink.ts +28 -0
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
coalescePrefixes,
|
|
4
|
+
isCoveredByAny,
|
|
5
|
+
grantPathToPrefix,
|
|
6
|
+
} from "./prefix-coalesce.js";
|
|
3
7
|
|
|
4
8
|
describe("coalescePrefixes", () => {
|
|
5
9
|
it("returns empty for empty input", () => {
|
|
@@ -93,3 +97,74 @@ describe("isCoveredByAny", () => {
|
|
|
93
97
|
expect(isCoveredByAny("A/b/c.md", ["a/"])).toBe(false);
|
|
94
98
|
});
|
|
95
99
|
});
|
|
100
|
+
|
|
101
|
+
describe("grantPathToPrefix", () => {
|
|
102
|
+
const slug = "indigo";
|
|
103
|
+
|
|
104
|
+
it("strips a companies/<slug>/ anchor", () => {
|
|
105
|
+
expect(grantPathToPrefix("companies/indigo/knowledge/README.md", slug)).toBe(
|
|
106
|
+
"knowledge/README.md",
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("strips a bare <slug>/ anchor", () => {
|
|
111
|
+
expect(grantPathToPrefix("indigo/data/vyg/old-meetings/*", slug)).toBe(
|
|
112
|
+
"data/vyg/old-meetings/",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("folds a trailing /* glob into a directory prefix", () => {
|
|
117
|
+
expect(grantPathToPrefix("data/vyg/*", slug)).toBe("data/vyg/");
|
|
118
|
+
expect(grantPathToPrefix("companies/indigo/design-pack/*", slug)).toBe(
|
|
119
|
+
"design-pack/",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("folds a trailing bare * into its parent prefix", () => {
|
|
124
|
+
expect(grantPathToPrefix("reports*", slug)).toBe("reports");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("leaves a company-relative directory prefix unchanged", () => {
|
|
128
|
+
expect(grantPathToPrefix("knowledge/security/", slug)).toBe(
|
|
129
|
+
"knowledge/security/",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("leaves a company-relative exact key unchanged", () => {
|
|
134
|
+
expect(grantPathToPrefix("company.yaml", slug)).toBe("company.yaml");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("maps a bare '*' (and empty) to '' = everything", () => {
|
|
138
|
+
expect(grantPathToPrefix("*", slug)).toBe("");
|
|
139
|
+
expect(grantPathToPrefix("", slug)).toBe("");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("tolerates leading slashes", () => {
|
|
143
|
+
expect(grantPathToPrefix("/companies/indigo/x/*", slug)).toBe("x/");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("does not strip a different company's anchor (defensive)", () => {
|
|
147
|
+
// A grant anchored at another slug is left intact (won't match this
|
|
148
|
+
// company's keys, which is the safe outcome).
|
|
149
|
+
expect(grantPathToPrefix("companies/other/x/*", slug)).toBe(
|
|
150
|
+
"companies/other/x/",
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("round-trips through coalescePrefixes + isCoveredByAny against real keys", () => {
|
|
155
|
+
const grants = [
|
|
156
|
+
"companies/indigo/design-pack/*",
|
|
157
|
+
"companies/indigo/knowledge/README.md",
|
|
158
|
+
"data/vyg/*",
|
|
159
|
+
"company.yaml",
|
|
160
|
+
];
|
|
161
|
+
const prefixes = coalescePrefixes(grants.map((g) => grantPathToPrefix(g, slug)));
|
|
162
|
+
expect(isCoveredByAny("design-pack/logo.svg", prefixes)).toBe(true);
|
|
163
|
+
expect(isCoveredByAny("knowledge/README.md", prefixes)).toBe(true);
|
|
164
|
+
expect(isCoveredByAny("data/vyg/2026/q1.md", prefixes)).toBe(true);
|
|
165
|
+
expect(isCoveredByAny("company.yaml", prefixes)).toBe(true);
|
|
166
|
+
// Out of scope:
|
|
167
|
+
expect(isCoveredByAny("secrets/db.json", prefixes)).toBe(false);
|
|
168
|
+
expect(isCoveredByAny("knowledge/OTHER.md", prefixes)).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
});
|
package/src/prefix-coalesce.ts
CHANGED
|
@@ -70,3 +70,48 @@ export function isCoveredByAny(
|
|
|
70
70
|
}
|
|
71
71
|
return false;
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Normalize a raw ACL grant `path` into a COMPANY-RELATIVE prefix suitable
|
|
76
|
+
* for `coalescePrefixes` + `isCoveredByAny` (which do literal `startsWith`
|
|
77
|
+
* matching against company-relative `RemoteFile.key`s).
|
|
78
|
+
*
|
|
79
|
+
* Real-world grant paths are inconsistent — observed forms in production:
|
|
80
|
+
* - `*` → everything (company-wide glob)
|
|
81
|
+
* - `companies/<slug>/design-pack/*` → full-anchored + trailing glob
|
|
82
|
+
* - `companies/<slug>/knowledge/README.md` → full-anchored exact file
|
|
83
|
+
* - `<slug>/data/vyg/old-meetings/*` → slug-anchored + trailing glob
|
|
84
|
+
* - `data/vyg/*` → company-relative + trailing glob
|
|
85
|
+
* - `company.yaml` → company-relative exact file
|
|
86
|
+
*
|
|
87
|
+
* The engine's keys are ALWAYS company-relative (`design-pack/x`,
|
|
88
|
+
* `company.yaml`). So we:
|
|
89
|
+
* 1. strip a leading `companies/<slug>/` or `<slug>/` anchor, and
|
|
90
|
+
* 2. fold the ACL glob suffix into a `startsWith`-friendly prefix:
|
|
91
|
+
* - `*` (bare) → `""` (covers everything)
|
|
92
|
+
* - `foo/bar/*` or `foo/bar*` → `foo/bar/` / `foo/bar`
|
|
93
|
+
* - `foo/bar/` (dir) → unchanged
|
|
94
|
+
* - `foo/bar.md` (exact) → unchanged (an exact key is its own prefix)
|
|
95
|
+
*
|
|
96
|
+
* Without this, `coalescePrefixes(grants.map(g => g.path))` produced prefixes
|
|
97
|
+
* with trailing `*` (which never `startsWith`-match a real key) and
|
|
98
|
+
* full/slug-anchored prefixes (which never match company-relative keys) — so
|
|
99
|
+
* `syncMode: shared` would download nothing and prune everything the caller
|
|
100
|
+
* actually has access to. See the live grant dump in the hq-pro vault.
|
|
101
|
+
*/
|
|
102
|
+
export function grantPathToPrefix(grantPath: string, slug: string): string {
|
|
103
|
+
let p = (grantPath ?? "").replace(/^\/+/, "");
|
|
104
|
+
// 1. Strip the company anchor, if present.
|
|
105
|
+
const companyAnchor = `companies/${slug}/`;
|
|
106
|
+
const slugAnchor = `${slug}/`;
|
|
107
|
+
if (p.startsWith(companyAnchor)) {
|
|
108
|
+
p = p.slice(companyAnchor.length);
|
|
109
|
+
} else if (p.startsWith(slugAnchor)) {
|
|
110
|
+
p = p.slice(slugAnchor.length);
|
|
111
|
+
}
|
|
112
|
+
// 2. Fold the ACL glob suffix into a startsWith prefix.
|
|
113
|
+
if (p === "*" || p === "") return ""; // company-wide / empty → everything
|
|
114
|
+
if (p.endsWith("/*")) return p.slice(0, -1); // "a/b/*" → "a/b/"
|
|
115
|
+
if (p.endsWith("*")) return p.slice(0, -1); // "a/b*" → "a/b"
|
|
116
|
+
return p; // dir prefix ("a/b/") or exact key ("a/b.md") — already a prefix
|
|
117
|
+
}
|
package/src/scope-shrink.ts
CHANGED
|
@@ -203,6 +203,34 @@ export class ScopeShrinkBlockedError extends Error {
|
|
|
203
203
|
}
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Structured error thrown when an AUTOMATIC scope shrink would prune more
|
|
208
|
+
* CLEAN local files than the configured safety cap in a single pull. This is
|
|
209
|
+
* the bulk-delete guard: a routine background sync should never silently
|
|
210
|
+
* delete a large local tree — whether from a deliberate-but-abrupt first
|
|
211
|
+
* narrow, a server bug returning an unexpectedly small grant set, or a
|
|
212
|
+
* mis-resolved scope. The operator runs the explicit `hq sync narrow` ritual
|
|
213
|
+
* (which has its own confirmation + dirty gate) or passes `--force-scope-shrink`
|
|
214
|
+
* to proceed. The engine never deletes anything when it throws this.
|
|
215
|
+
*/
|
|
216
|
+
export class ScopeShrinkLargePruneError extends Error {
|
|
217
|
+
readonly code = "SCOPE_SHRINK_LARGE_PRUNE";
|
|
218
|
+
constructor(
|
|
219
|
+
public readonly companyUid: string,
|
|
220
|
+
public readonly toMode: PullRecord["syncMode"],
|
|
221
|
+
public readonly cleanCount: number,
|
|
222
|
+
public readonly cap: number,
|
|
223
|
+
) {
|
|
224
|
+
super(
|
|
225
|
+
`Refusing to auto-prune ${cleanCount} local file(s) for ${companyUid} ` +
|
|
226
|
+
`(${toMode} scope) in one sync — exceeds the safety cap of ${cap}. ` +
|
|
227
|
+
`Run \`hq sync narrow --apply\` to migrate with confirmation, raise ` +
|
|
228
|
+
`HQ_SYNC_MAX_AUTO_PRUNE, or pass --force-scope-shrink.`,
|
|
229
|
+
);
|
|
230
|
+
this.name = "ScopeShrinkLargePruneError";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
206
234
|
export interface ApplyScopeShrinkInput {
|
|
207
235
|
journal: SyncJournal;
|
|
208
236
|
plan: ScopeShrinkPlan;
|