@indigoai-us/hq-cloud 6.14.36 → 6.14.39

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.
Files changed (43) hide show
  1. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-company.js +20 -3
  3. package/dist/bin/sync-runner-company.js.map +1 -1
  4. package/dist/bin/sync-runner.d.ts +5 -0
  5. package/dist/bin/sync-runner.d.ts.map +1 -1
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/bin/sync-runner.test.js +46 -0
  8. package/dist/bin/sync-runner.test.js.map +1 -1
  9. package/dist/cli/reindex.d.ts.map +1 -1
  10. package/dist/cli/reindex.js +1 -9
  11. package/dist/cli/reindex.js.map +1 -1
  12. package/dist/cli/share.d.ts +178 -1
  13. package/dist/cli/share.d.ts.map +1 -1
  14. package/dist/cli/share.js +555 -32
  15. package/dist/cli/share.js.map +1 -1
  16. package/dist/cli/share.test.js +780 -2
  17. package/dist/cli/share.test.js.map +1 -1
  18. package/dist/cli/sync.d.ts +27 -0
  19. package/dist/cli/sync.d.ts.map +1 -1
  20. package/dist/cli/sync.js +113 -13
  21. package/dist/cli/sync.js.map +1 -1
  22. package/dist/cli/sync.test.js +188 -0
  23. package/dist/cli/sync.test.js.map +1 -1
  24. package/dist/lib/readlink-safe.d.ts +11 -0
  25. package/dist/lib/readlink-safe.d.ts.map +1 -0
  26. package/dist/lib/readlink-safe.js +27 -0
  27. package/dist/lib/readlink-safe.js.map +1 -0
  28. package/dist/lib/readlink-safe.test.d.ts +2 -0
  29. package/dist/lib/readlink-safe.test.d.ts.map +1 -0
  30. package/dist/lib/readlink-safe.test.js +34 -0
  31. package/dist/lib/readlink-safe.test.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/bin/sync-runner-company.ts +19 -3
  34. package/src/bin/sync-runner.test.ts +54 -0
  35. package/src/bin/sync-runner.ts +4 -0
  36. package/src/cli/reindex.ts +1 -9
  37. package/src/cli/share.test.ts +974 -2
  38. package/src/cli/share.ts +675 -32
  39. package/src/cli/sync.test.ts +209 -0
  40. package/src/cli/sync.ts +151 -13
  41. package/src/lib/readlink-safe.test.ts +43 -0
  42. package/src/lib/readlink-safe.ts +29 -0
  43. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +191 -0
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Read a link target without turning an expected filesystem race or an
3
+ * unsupported link shape into a caller-wide failure.
4
+ *
5
+ * A `null` result is deliberately distinct from an empty target string: it
6
+ * means the caller must preserve the link boundary and choose its conservative
7
+ * skip/defer path. Non-filesystem exceptions still propagate so programming
8
+ * errors cannot be mistaken for an unreadable link.
9
+ */
10
+ export declare function readlinkOrNull(linkPath: string, readlink?: (path: string) => string): string | null;
11
+ //# sourceMappingURL=readlink-safe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readlink-safe.d.ts","sourceRoot":"","sources":["../../src/lib/readlink-safe.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,EAChB,QAAQ,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAgD,GAC3E,MAAM,GAAG,IAAI,CAcf"}
@@ -0,0 +1,27 @@
1
+ import * as fs from "fs";
2
+ /**
3
+ * Read a link target without turning an expected filesystem race or an
4
+ * unsupported link shape into a caller-wide failure.
5
+ *
6
+ * A `null` result is deliberately distinct from an empty target string: it
7
+ * means the caller must preserve the link boundary and choose its conservative
8
+ * skip/defer path. Non-filesystem exceptions still propagate so programming
9
+ * errors cannot be mistaken for an unreadable link.
10
+ */
11
+ export function readlinkOrNull(linkPath, readlink = (path) => fs.readlinkSync(path, "utf8")) {
12
+ try {
13
+ return readlink(linkPath);
14
+ }
15
+ catch (err) {
16
+ const code = err && typeof err === "object" && "code" in err
17
+ ? err.code
18
+ : undefined;
19
+ // Node system errors use POSIX-style errno names such as EINVAL and
20
+ // ENOENT. Runtime/programming errors use ERR_* codes; those must remain
21
+ // loud rather than being misclassified as an unreadable link.
22
+ if (typeof code === "string" && /^E[A-Z0-9]+$/.test(code))
23
+ return null;
24
+ throw err;
25
+ }
26
+ }
27
+ //# sourceMappingURL=readlink-safe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readlink-safe.js","sourceRoot":"","sources":["../../src/lib/readlink-safe.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AAEzB;;;;;;;;GAQG;AACH,MAAM,UAAU,cAAc,CAC5B,QAAgB,EAChB,WAAqC,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC;IAE5E,IAAI,CAAC;QACH,OAAO,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAC5B,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACtB,MAAM,IAAI,GACR,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,MAAM,IAAI,GAAG;YAC7C,CAAC,CAAE,GAA0B,CAAC,IAAI;YAClC,CAAC,CAAC,SAAS,CAAC;QAChB,oEAAoE;QACpE,wEAAwE;QACxE,8DAA8D;QAC9D,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;QACvE,MAAM,GAAG,CAAC;IACZ,CAAC;AACH,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=readlink-safe.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readlink-safe.test.d.ts","sourceRoot":"","sources":["../../src/lib/readlink-safe.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readlinkOrNull } from "./readlink-safe.js";
3
+ function errnoError(code) {
4
+ const err = new Error(`${code}: scripted readlink failure`);
5
+ err.code = code;
6
+ return err;
7
+ }
8
+ describe("readlinkOrNull", () => {
9
+ it("uses the filesystem reader by default", () => {
10
+ expect(readlinkOrNull(`/hq-cloud-missing-link-${process.pid}`)).toBeNull();
11
+ });
12
+ it("returns the target string verbatim when readlink succeeds", () => {
13
+ expect(readlinkOrNull("/test/link", () => "../target with spaces")).toBe("../target with spaces");
14
+ });
15
+ it.each(["EINVAL", "ENOENT", "EPERM"])("returns null for %s", (code) => {
16
+ expect(readlinkOrNull("/test/link", () => {
17
+ throw errnoError(code);
18
+ })).toBeNull();
19
+ });
20
+ it("rethrows non-filesystem failures", () => {
21
+ const defect = new Error("unexpected test defect");
22
+ expect(() => readlinkOrNull("/test/link", () => {
23
+ throw defect;
24
+ })).toThrow(defect);
25
+ });
26
+ it("rethrows code-bearing programming errors", () => {
27
+ const defect = new TypeError("path must be a string");
28
+ defect.code = "ERR_INVALID_ARG_TYPE";
29
+ expect(() => readlinkOrNull("/test/link", () => {
30
+ throw defect;
31
+ })).toThrow(defect);
32
+ });
33
+ });
34
+ //# sourceMappingURL=readlink-safe.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readlink-safe.test.js","sourceRoot":"","sources":["../../src/lib/readlink-safe.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAEpD,SAAS,UAAU,CAAC,IAAY;IAC9B,MAAM,GAAG,GAAG,IAAI,KAAK,CAAC,GAAG,IAAI,6BAA6B,CAA0B,CAAC;IACrF,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;IAChB,OAAO,GAAG,CAAC;AACb,CAAC;AAED,QAAQ,CAAC,gBAAgB,EAAE,GAAG,EAAE;IAC9B,EAAE,CAAC,uCAAuC,EAAE,GAAG,EAAE;QAC/C,MAAM,CAAC,cAAc,CAAC,0BAA0B,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;IAC7E,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,2DAA2D,EAAE,GAAG,EAAE;QACnE,MAAM,CAAC,cAAc,CAAC,YAAY,EAAE,GAAG,EAAE,CAAC,uBAAuB,CAAC,CAAC,CAAC,IAAI,CACtE,uBAAuB,CACxB,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,qBAAqB,EAAE,CAAC,IAAI,EAAE,EAAE;QACrE,MAAM,CAAC,cAAc,CAAC,YAAY,EAAE,GAAG,EAAE;YACvC,MAAM,UAAU,CAAC,IAAI,CAAC,CAAC;QACzB,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;IACjB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,kCAAkC,EAAE,GAAG,EAAE;QAC1C,MAAM,MAAM,GAAG,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAC;QAEnD,MAAM,CAAC,GAAG,EAAE,CAAC,cAAc,CAAC,YAAY,EAAE,GAAG,EAAE;YAC7C,MAAM,MAAM,CAAC;QACf,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACtB,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,0CAA0C,EAAE,GAAG,EAAE;QAClD,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC,uBAAuB,CAAiC,CAAC;QACtF,MAAM,CAAC,IAAI,GAAG,sBAAsB,CAAC;QAErC,MAAM,CAAC,GAAG,EAAE,CAAC,cAAc,CAAC,YAAY,EAAE,GAAG,EAAE;YAC7C,MAAM,MAAM,CAAC;QACf,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACtB,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.14.36",
3
+ "version": "6.14.39",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -183,15 +183,20 @@ export async function executeCompanyFanout(options: {
183
183
  ...(event.errorCode ? { errorCode: event.errorCode } : {}),
184
184
  });
185
185
  } else if (event.type === "skip-junk-key-spelling") {
186
- // Deliberate skip: forwarded for visibility, NEVER pushed into
187
- // errors[] (policy hq-sync-deliberate-skip-not-fatal-error-exit2 —
188
- // a benign recurring skip must not gate exit 2 / Sentry alerts).
189
186
  options.emit({
190
187
  type: "skip-junk-key-spelling",
191
188
  company: companyLabel,
192
189
  path: event.path,
193
190
  journaledKey: event.journaledKey,
194
191
  });
192
+ } else if (event.type === "not-shipped") {
193
+ options.emit({
194
+ type: "not-shipped",
195
+ company: companyLabel,
196
+ reason: event.reason,
197
+ count: event.count,
198
+ samplePaths: event.samplePaths,
199
+ });
195
200
  }
196
201
  };
197
202
 
@@ -208,6 +213,8 @@ export async function executeCompanyFanout(options: {
208
213
  filesExcludedByPolicy: 0,
209
214
  filesExcludedByScope: 0,
210
215
  filesExcludedByIgnore: 0,
216
+ unreachablePaths: [],
217
+ linkedSubtreesNotShipped: [],
211
218
  conflictPaths: [],
212
219
  aborted: false,
213
220
  };
@@ -288,6 +295,15 @@ export async function executeCompanyFanout(options: {
288
295
  skipUnchanged: true,
289
296
  propagateDeletes: true,
290
297
  propagateDeletePolicy: options.resolveDeletePolicy(),
298
+ // `pushPaths` are INTERNAL walk roots (the company folder / the
299
+ // personal-vault dirs), never user input, so an unreachable entry
300
+ // here is a benign race — a directory removed between the scan and
301
+ // the push — and must not fail an unattended background sync. Record
302
+ // + report it (`not-shipped` event, `ShareResult.unreachablePaths`)
303
+ // and carry on. The interactive `hq sync push <path>` keeps the
304
+ // default "error" policy, which is where a user-typed path that
305
+ // silently ships nothing is a real bug (feedback_a51cb63d ask #2).
306
+ unreachablePathPolicy: "warn",
291
307
  onEvent: tagAndEmit,
292
308
  ...(options.uploadAuthor ? { author: options.uploadAuthor } : {}),
293
309
  ...(target.personalMode !== undefined
@@ -157,6 +157,8 @@ function defaultShareResult(overrides: Partial<ShareResult> = {}): ShareResult {
157
157
  filesExcludedByPolicy: 0,
158
158
  filesExcludedByScope: 0,
159
159
  filesExcludedByIgnore: 0,
160
+ unreachablePaths: [],
161
+ linkedSubtreesNotShipped: [],
160
162
  conflictPaths: [],
161
163
  aborted: false,
162
164
  ...overrides,
@@ -2025,6 +2027,58 @@ describe("per-company fanout", () => {
2025
2027
  // ---------------------------------------------------------------------------
2026
2028
 
2027
2029
  describe("all-complete aggregate", () => {
2030
+ it("reports every unreadable-link fallback by path without turning the company leg into PARTIAL_SYNC_EXIT", async () => {
2031
+ const unreadablePaths = [
2032
+ "policies/scope-invalid-tombstone",
2033
+ "docs/conflict-convergence-probe",
2034
+ "skills/downloaded-link-hash",
2035
+ ];
2036
+ const deps = makeDeps({
2037
+ createVaultClient: () =>
2038
+ makeVaultStub({
2039
+ memberships: [{ companyUid: "cmp_a" }],
2040
+ entityGet: (uid: string) =>
2041
+ Promise.resolve({
2042
+ uid,
2043
+ type: "company",
2044
+ slug: "acme",
2045
+ bucketName: "hq-vault-acme-test",
2046
+ status: "active",
2047
+ } as unknown as EntityInfo),
2048
+ }),
2049
+ share: vi.fn(async (options: ShareOptions) => {
2050
+ for (const path of unreadablePaths) {
2051
+ options.onEvent?.({
2052
+ type: "not-shipped",
2053
+ reason: "unreadable-link",
2054
+ count: 1,
2055
+ samplePaths: [path],
2056
+ });
2057
+ }
2058
+ return defaultShareResult();
2059
+ }),
2060
+ });
2061
+
2062
+ const code = await runRunner(["--companies", "--direction", "push"], deps);
2063
+
2064
+ for (const path of unreadablePaths) {
2065
+ expect(deps.stdout.events()).toContainEqual({
2066
+ type: "not-shipped",
2067
+ company: "acme",
2068
+ reason: "unreadable-link",
2069
+ count: 1,
2070
+ samplePaths: [path],
2071
+ });
2072
+ }
2073
+ const all = deps.stdout.events().find(
2074
+ (event): event is Extract<RunnerEvent, { type: "all-complete" }> =>
2075
+ event.type === "all-complete",
2076
+ );
2077
+ expect(all?.errors).toEqual([]);
2078
+ expect(all?.partial).toBe(false);
2079
+ expect(code).toBe(0);
2080
+ });
2081
+
2028
2082
  it("emits all-complete when telemetry never resolves", async () => {
2029
2083
  const deps = makeDeps({
2030
2084
  createVaultClient: () =>
@@ -402,6 +402,10 @@ export type RunnerEvent =
402
402
  Extract<SyncProgressEvent, { type: "skip-junk-key-spelling" }>,
403
403
  "type"
404
404
  >)
405
+ | ({ type: "not-shipped"; company: string } & Omit<
406
+ Extract<SyncProgressEvent, { type: "not-shipped" }>,
407
+ "type"
408
+ >)
405
409
  | ({
406
410
  type: "complete";
407
411
  company: string;
@@ -22,6 +22,7 @@ import { spawnSync } from "child_process";
22
22
  import * as fs from "fs";
23
23
  import * as os from "os";
24
24
  import * as path from "path";
25
+ import { readlinkOrNull } from "../lib/readlink-safe.js";
25
26
  import {
26
27
  acquireOperationLock,
27
28
  OperationLockedError,
@@ -126,15 +127,6 @@ function lstatOrNull(p: string): fs.Stats | null {
126
127
  }
127
128
  }
128
129
 
129
- /** readlink without throwing (null when not a symlink / missing). */
130
- function readlinkOrNull(p: string): string | null {
131
- try {
132
- return fs.readlinkSync(p);
133
- } catch {
134
- return null;
135
- }
136
- }
137
-
138
130
  /**
139
131
  * Entries of `dir` matching a bash `dir/*` glob: dotfiles excluded (no
140
132
  * dotglob), sorted lexicographically (bash glob order). Missing dir → [].