@indigoai-us/hq-cloud 5.31.0 → 5.33.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.
Files changed (69) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +79 -20
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/bin/sync-runner.test.js +292 -0
  5. package/dist/bin/sync-runner.test.js.map +1 -1
  6. package/dist/cli/share.d.ts +26 -0
  7. package/dist/cli/share.d.ts.map +1 -1
  8. package/dist/cli/share.js +132 -11
  9. package/dist/cli/share.js.map +1 -1
  10. package/dist/cli/share.test.js +236 -6
  11. package/dist/cli/share.test.js.map +1 -1
  12. package/dist/cli/sync.d.ts +28 -2
  13. package/dist/cli/sync.d.ts.map +1 -1
  14. package/dist/cli/sync.js +26 -5
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/cli/sync.test.js +82 -0
  17. package/dist/cli/sync.test.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/lib/conflict-file.d.ts +7 -6
  23. package/dist/lib/conflict-file.d.ts.map +1 -1
  24. package/dist/lib/conflict-file.js +7 -27
  25. package/dist/lib/conflict-file.js.map +1 -1
  26. package/dist/lib/conflict.test.d.ts +4 -3
  27. package/dist/lib/conflict.test.d.ts.map +1 -1
  28. package/dist/lib/conflict.test.js +5 -33
  29. package/dist/lib/conflict.test.js.map +1 -1
  30. package/dist/lib/describe-error.d.ts +21 -0
  31. package/dist/lib/describe-error.d.ts.map +1 -0
  32. package/dist/lib/describe-error.js +53 -0
  33. package/dist/lib/describe-error.js.map +1 -0
  34. package/dist/lib/describe-error.test.d.ts +2 -0
  35. package/dist/lib/describe-error.test.d.ts.map +1 -0
  36. package/dist/lib/describe-error.test.js +89 -0
  37. package/dist/lib/describe-error.test.js.map +1 -0
  38. package/dist/lib/machine-id.d.ts +108 -0
  39. package/dist/lib/machine-id.d.ts.map +1 -0
  40. package/dist/lib/machine-id.js +170 -0
  41. package/dist/lib/machine-id.js.map +1 -0
  42. package/dist/lib/machine-id.test.d.ts +8 -0
  43. package/dist/lib/machine-id.test.d.ts.map +1 -0
  44. package/dist/lib/machine-id.test.js +195 -0
  45. package/dist/lib/machine-id.test.js.map +1 -0
  46. package/dist/personal-vault.d.ts +63 -7
  47. package/dist/personal-vault.d.ts.map +1 -1
  48. package/dist/personal-vault.js +112 -8
  49. package/dist/personal-vault.js.map +1 -1
  50. package/dist/personal-vault.test.d.ts +14 -0
  51. package/dist/personal-vault.test.d.ts.map +1 -0
  52. package/dist/personal-vault.test.js +191 -0
  53. package/dist/personal-vault.test.js.map +1 -0
  54. package/package.json +1 -1
  55. package/src/bin/sync-runner.test.ts +364 -0
  56. package/src/bin/sync-runner.ts +83 -18
  57. package/src/cli/share.test.ts +269 -6
  58. package/src/cli/share.ts +169 -11
  59. package/src/cli/sync.test.ts +91 -0
  60. package/src/cli/sync.ts +57 -5
  61. package/src/index.ts +3 -0
  62. package/src/lib/conflict-file.ts +7 -27
  63. package/src/lib/conflict.test.ts +4 -40
  64. package/src/lib/describe-error.test.ts +100 -0
  65. package/src/lib/describe-error.ts +58 -0
  66. package/src/lib/machine-id.test.ts +221 -0
  67. package/src/lib/machine-id.ts +175 -0
  68. package/src/personal-vault.test.ts +231 -0
  69. package/src/personal-vault.ts +134 -8
@@ -915,6 +915,64 @@ describe("per-company fanout", () => {
915
915
  expect(betaComplete?.filesDownloaded).toBe(1);
916
916
  });
917
917
 
918
+ /**
919
+ * Regression test: AWS SDK v3 raises an opaque `UnknownError` (name and
920
+ * message both literally "UnknownError") when it cannot match a Node-side
921
+ * networking failure to a modeled exception. The actual root cause —
922
+ * ENOTFOUND from a stale DNS cache, ECONNRESET on a flapping link, etc. —
923
+ * is parked on `err.cause`. Pre-fix the per-company catch extracted only
924
+ * `err.message`, so an unactionable bare "UnknownError" reached operators
925
+ * and the underlying syscall/hostname were lost. The catch now runs the
926
+ * thrown value through `describeError`, which walks the cause chain and
927
+ * stitches a one-line diagnostic the operator can act on.
928
+ */
929
+ it("per-company error event surfaces cause-chain (ENOTFOUND from SDK UnknownError)", async () => {
930
+ const deps = makeDeps({
931
+ createVaultClient: () =>
932
+ makeVaultStub({
933
+ memberships: [{ companyUid: "cmp_x" }],
934
+ entityGet: (uid: string) =>
935
+ Promise.resolve({ uid, slug: "privy" } as unknown as EntityInfo),
936
+ }),
937
+ sync: vi
938
+ .fn<(opts: SyncOptions) => Promise<SyncResult>>()
939
+ .mockImplementationOnce(async () => {
940
+ // Shape mirrors @aws-sdk/client-s3 v3 behaviour when the underlying
941
+ // node-http-handler raises a getaddrinfo failure: opaque wrapper
942
+ // with the real error on `.cause`.
943
+ const sdkErr = new Error("UnknownError");
944
+ sdkErr.name = "UnknownError";
945
+ const dnsErr = new Error("getaddrinfo ENOTFOUND privy.s3.us-east-1.amazonaws.com");
946
+ (dnsErr as Error & { code?: string; syscall?: string; hostname?: string }).code =
947
+ "ENOTFOUND";
948
+ (dnsErr as Error & { code?: string; syscall?: string; hostname?: string }).syscall =
949
+ "getaddrinfo";
950
+ (dnsErr as Error & { code?: string; syscall?: string; hostname?: string }).hostname =
951
+ "privy.s3.us-east-1.amazonaws.com";
952
+ (sdkErr as Error & { cause?: unknown }).cause = dnsErr;
953
+ throw sdkErr;
954
+ }),
955
+ });
956
+
957
+ const code = await runRunner(["--companies"], deps);
958
+ expect(code).toBe(2);
959
+
960
+ const companyErr = deps.stderr
961
+ .events()
962
+ .find(
963
+ (e): e is Extract<RunnerEvent, { type: "error" }> =>
964
+ e.type === "error" && e.company === "privy",
965
+ );
966
+ expect(companyErr).toBeDefined();
967
+ expect(companyErr?.path).toBe("(company)");
968
+ // The diagnostic must carry the original networking signal — without
969
+ // this the operator only sees "UnknownError" and the run is unactionable.
970
+ expect(companyErr?.message).toContain("UnknownError");
971
+ expect(companyErr?.message).toContain("ENOTFOUND");
972
+ expect(companyErr?.message).toContain("getaddrinfo");
973
+ expect(companyErr?.message).toContain("privy.s3.us-east-1.amazonaws.com");
974
+ });
975
+
918
976
  /**
919
977
  * Regression test for the rollup-bug from the personal-sync 401 incident.
920
978
  *
@@ -1638,6 +1696,312 @@ describe("personal slot fanout", () => {
1638
1696
  }
1639
1697
  });
1640
1698
 
1699
+ it("G: personal slot includes companies/{slug}/ subdirs when cloud:false marker is set AND HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1, excluding _template + team-synced slugs + missing/cloud:true markers", async () => {
1700
+ // Gate the new behavior: without this env var the runner falls back to
1701
+ // the legacy "companies/ never enumerated" personal vault. Test H below
1702
+ // pins the OFF case.
1703
+ vi.stubEnv("HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL", "1");
1704
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
1705
+ const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
1706
+ try {
1707
+ // Top-level personal-vault content — anchors the assertion that the
1708
+ // existing top-level walk still runs alongside the new company-subdir
1709
+ // discovery.
1710
+ fs.mkdirSync(path.join(tmpHqRoot, "knowledge"));
1711
+
1712
+ // companies/ subdirs covering every branch of the eligibility filter.
1713
+ const mkCompany = (slug: string, yaml: string | null): void => {
1714
+ const dir = path.join(tmpHqRoot, "companies", slug);
1715
+ fs.mkdirSync(dir, { recursive: true });
1716
+ if (yaml !== null) {
1717
+ fs.writeFileSync(path.join(dir, "company.yaml"), yaml);
1718
+ }
1719
+ };
1720
+ mkCompany("free-co", "cloud: false\nname: Free Co\n"); // INCLUDED
1721
+ mkCompany("acme", "cloud: false\n"); // EXCLUDED — team-synced (membership below)
1722
+ mkCompany("_template", "cloud: false\n"); // EXCLUDED — hard-listed slug
1723
+ mkCompany("cloud-team", "cloud: true\n"); // EXCLUDED — wrong marker
1724
+ mkCompany("zilch", null); // EXCLUDED — no company.yaml at all
1725
+
1726
+ const deps = makeDeps({
1727
+ createVaultClient: () =>
1728
+ makeVaultStub({
1729
+ memberships: [{ companyUid: "cmp_a" }],
1730
+ entityGet: (uid: string) =>
1731
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1732
+ listPersons: () => Promise.resolve([olderPerson]),
1733
+ }),
1734
+ share: shareSpy,
1735
+ });
1736
+
1737
+ const code = await runRunner(
1738
+ ["--companies", "--direction", "push", "--hq-root", tmpHqRoot],
1739
+ deps,
1740
+ );
1741
+ expect(code).toBe(0);
1742
+
1743
+ const personalCall = (shareSpy.mock.calls as Array<[ShareOptions]>).find(
1744
+ (c) => c[0].company?.startsWith("prs_"),
1745
+ );
1746
+ expect(personalCall).toBeDefined();
1747
+ const personalArgs = personalCall![0];
1748
+
1749
+ // Convert paths to a normalized form (relative to tmpHqRoot) for
1750
+ // easier matching. Comparing as a set since fs.readdirSync order is
1751
+ // not guaranteed.
1752
+ const rel = personalArgs.paths
1753
+ .map((p) => path.relative(tmpHqRoot, p))
1754
+ .sort();
1755
+
1756
+ // free-co MUST be present (cloud:false, no membership, not _template).
1757
+ expect(rel).toContain(path.join("companies", "free-co"));
1758
+ // knowledge/ MUST still be present (top-level walk unchanged).
1759
+ expect(rel).toContain("knowledge");
1760
+
1761
+ // All other companies MUST be filtered out.
1762
+ for (const forbidden of [
1763
+ path.join("companies", "acme"), // team-synced — goes to cmp_a's bucket
1764
+ path.join("companies", "_template"), // hard-listed
1765
+ path.join("companies", "cloud-team"), // cloud:true
1766
+ path.join("companies", "zilch"), // no marker
1767
+ ]) {
1768
+ expect(rel).not.toContain(forbidden);
1769
+ }
1770
+
1771
+ // The top-level `companies/` directory itself MUST NOT be in the
1772
+ // share list — only specific opt-in subdirs are. This preserves the
1773
+ // invariant that share() never walks the whole companies/ tree.
1774
+ expect(rel).not.toContain("companies");
1775
+
1776
+ // The team target for cmp_a must still receive the unchanged
1777
+ // `companies/acme` path (not double-routed to the personal slot).
1778
+ const acmeCall = (shareSpy.mock.calls as Array<[ShareOptions]>).find(
1779
+ (c) => c[0].company === "cmp_a",
1780
+ );
1781
+ expect(acmeCall).toBeDefined();
1782
+ expect(acmeCall![0].paths).toEqual([
1783
+ path.join(tmpHqRoot, "companies", "acme"),
1784
+ ]);
1785
+ } finally {
1786
+ fs.rmSync(tmpHqRoot, { recursive: true, force: true });
1787
+ vi.unstubAllEnvs();
1788
+ }
1789
+ });
1790
+
1791
+ it("H: with HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL unset, companies/ subdirs are NEVER added to the personal slot — even with a valid cloud:false marker", async () => {
1792
+ // Explicitly assert the OFF case: the env var is the gate. Without it,
1793
+ // a `cloud: false` marker is necessary-but-insufficient; the personal
1794
+ // vault stays in its pre-5.20 shape (top-level entries only, never
1795
+ // any company subdir). Defensive `unstub` in case a leaked stub from
1796
+ // an earlier test bleeds into this one.
1797
+ vi.unstubAllEnvs();
1798
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
1799
+ const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
1800
+ try {
1801
+ fs.mkdirSync(path.join(tmpHqRoot, "knowledge"));
1802
+ // Same fixture as G — but the gate is off, so free-co must NOT appear.
1803
+ const dir = path.join(tmpHqRoot, "companies", "free-co");
1804
+ fs.mkdirSync(dir, { recursive: true });
1805
+ fs.writeFileSync(path.join(dir, "company.yaml"), "cloud: false\n");
1806
+
1807
+ const deps = makeDeps({
1808
+ createVaultClient: () =>
1809
+ makeVaultStub({
1810
+ memberships: [{ companyUid: "cmp_a" }],
1811
+ entityGet: (uid: string) =>
1812
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1813
+ listPersons: () => Promise.resolve([olderPerson]),
1814
+ }),
1815
+ share: shareSpy,
1816
+ });
1817
+
1818
+ const code = await runRunner(
1819
+ ["--companies", "--direction", "push", "--hq-root", tmpHqRoot],
1820
+ deps,
1821
+ );
1822
+ expect(code).toBe(0);
1823
+
1824
+ const personalCall = (shareSpy.mock.calls as Array<[ShareOptions]>).find(
1825
+ (c) => c[0].company?.startsWith("prs_"),
1826
+ );
1827
+ expect(personalCall).toBeDefined();
1828
+ const rel = personalCall![0].paths
1829
+ .map((p) => path.relative(tmpHqRoot, p))
1830
+ .sort();
1831
+
1832
+ // free-co must NOT appear because the env-var gate is off.
1833
+ expect(rel).not.toContain(path.join("companies", "free-co"));
1834
+ // knowledge/ must still appear — the legacy top-level walk is unchanged.
1835
+ expect(rel).toContain("knowledge");
1836
+ } finally {
1837
+ fs.rmSync(tmpHqRoot, { recursive: true, force: true });
1838
+ }
1839
+ });
1840
+
1841
+ it("I: personal slot receives decommissionPrefixes = ['companies/{slug}'] for every team-synced slug; company slots receive none", async () => {
1842
+ // Decommission cleanup runs UNCONDITIONALLY — not gated on
1843
+ // HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL — because the off-ramp must
1844
+ // always be safe to traverse. A user who turned the feature off after
1845
+ // syncing some companies to personal would otherwise be stuck with
1846
+ // permanent orphans.
1847
+ vi.unstubAllEnvs();
1848
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
1849
+ const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
1850
+ try {
1851
+ const deps = makeDeps({
1852
+ createVaultClient: () =>
1853
+ makeVaultStub({
1854
+ // Two team-backed companies — both slugs should land in the
1855
+ // personal slot's decommissionPrefixes list.
1856
+ memberships: [
1857
+ { companyUid: "cmp_a" },
1858
+ { companyUid: "cmp_b" },
1859
+ ],
1860
+ entityGet: (uid: string) =>
1861
+ Promise.resolve({
1862
+ uid,
1863
+ slug: uid === "cmp_a" ? "acme" : "beta",
1864
+ } as unknown as EntityInfo),
1865
+ listPersons: () => Promise.resolve([olderPerson]),
1866
+ }),
1867
+ share: shareSpy,
1868
+ });
1869
+
1870
+ const code = await runRunner(
1871
+ ["--companies", "--direction", "push", "--hq-root", tmpHqRoot],
1872
+ deps,
1873
+ );
1874
+ expect(code).toBe(0);
1875
+
1876
+ // Personal slot: decommissionPrefixes covers both team-synced slugs.
1877
+ const personalCall = (shareSpy.mock.calls as Array<[ShareOptions]>).find(
1878
+ (c) => c[0].company?.startsWith("prs_"),
1879
+ );
1880
+ expect(personalCall).toBeDefined();
1881
+ const personalArgs = personalCall![0] as ShareOptions & {
1882
+ decommissionPrefixes?: string[];
1883
+ };
1884
+ expect(personalArgs.decommissionPrefixes?.sort()).toEqual([
1885
+ "companies/acme",
1886
+ "companies/beta",
1887
+ ]);
1888
+
1889
+ // Company slots: NO decommissionPrefixes key at all (preserves the
1890
+ // contract that company-target args stay identical to pre-decommission
1891
+ // shape — symmetric to test F's personalMode/journalSlug pin).
1892
+ const companyCalls = (shareSpy.mock.calls as Array<[ShareOptions]>).filter(
1893
+ (c) => c[0].company?.startsWith("cmp_"),
1894
+ );
1895
+ expect(companyCalls.length).toBeGreaterThan(0);
1896
+ for (const [args] of companyCalls) {
1897
+ expect(Object.keys(args)).not.toContain("decommissionPrefixes");
1898
+ }
1899
+ } finally {
1900
+ fs.rmSync(tmpHqRoot, { recursive: true, force: true });
1901
+ }
1902
+ });
1903
+
1904
+ it("J: personal slot's syncFn (pull) receives includeLocalCompanies + teamSyncedSlugs; company slots receive neither", async () => {
1905
+ // Symmetric to test I (push-side decommissionPrefixes). The pull side
1906
+ // needs the same context to:
1907
+ // • allow `companies/{cloud-false-slug}/...` keys through when the
1908
+ // operator opts in (HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1)
1909
+ // • drop `companies/{team-synced-slug}/...` orphans even when push-
1910
+ // side decommission hasn't run (e.g. pull-only mode)
1911
+ //
1912
+ // Default direction for runRunner is "pull" so this test exercises
1913
+ // the pull call without needing --direction.
1914
+ vi.stubEnv("HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL", "1");
1915
+ const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
1916
+ try {
1917
+ const deps = makeDeps({
1918
+ createVaultClient: () =>
1919
+ makeVaultStub({
1920
+ memberships: [
1921
+ { companyUid: "cmp_a" },
1922
+ { companyUid: "cmp_b" },
1923
+ ],
1924
+ entityGet: (uid: string) =>
1925
+ Promise.resolve({
1926
+ uid,
1927
+ slug: uid === "cmp_a" ? "acme" : "beta",
1928
+ } as unknown as EntityInfo),
1929
+ listPersons: () => Promise.resolve([olderPerson]),
1930
+ }),
1931
+ sync: syncSpy,
1932
+ });
1933
+
1934
+ const code = await runRunner(["--companies"], deps);
1935
+ expect(code).toBe(0);
1936
+
1937
+ // Personal slot: both args present, populated with the team-synced
1938
+ // slug set + the env-var-derived gate flag.
1939
+ const personalCall = (syncSpy.mock.calls as Array<[SyncOptions]>).find(
1940
+ (c) => c[0].company?.startsWith("prs_"),
1941
+ );
1942
+ expect(personalCall).toBeDefined();
1943
+ const personalArgs = personalCall![0] as SyncOptions & {
1944
+ includeLocalCompanies?: boolean;
1945
+ teamSyncedSlugs?: ReadonlySet<string>;
1946
+ };
1947
+ expect(personalArgs.includeLocalCompanies).toBe(true);
1948
+ expect(Array.from(personalArgs.teamSyncedSlugs ?? []).sort()).toEqual([
1949
+ "acme",
1950
+ "beta",
1951
+ ]);
1952
+
1953
+ // Company slots: NEITHER key present (preserves the contract that
1954
+ // company-target args stay identical to pre-Slice-2 shape — symmetric
1955
+ // to test C's personalMode/journalSlug pin and test I's
1956
+ // decommissionPrefixes pin).
1957
+ const companyCalls = (syncSpy.mock.calls as Array<[SyncOptions]>).filter(
1958
+ (c) => c[0].company?.startsWith("cmp_"),
1959
+ );
1960
+ expect(companyCalls.length).toBeGreaterThan(0);
1961
+ for (const [args] of companyCalls) {
1962
+ expect(Object.keys(args)).not.toContain("includeLocalCompanies");
1963
+ expect(Object.keys(args)).not.toContain("teamSyncedSlugs");
1964
+ }
1965
+ } finally {
1966
+ vi.unstubAllEnvs();
1967
+ }
1968
+ });
1969
+
1970
+ it("K: personal slot's syncFn (pull) sets includeLocalCompanies=false when env var is unset — pull stays in legacy shape", async () => {
1971
+ // Symmetric to test H (env-var-OFF case for push). Without the gate,
1972
+ // the pull side preserves the pre-5.20 "drop all companies/... keys"
1973
+ // contract regardless of what's actually in the personal bucket.
1974
+ // Defensive unstub in case a previous test leaked a stub.
1975
+ vi.unstubAllEnvs();
1976
+ const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
1977
+ const deps = makeDeps({
1978
+ createVaultClient: () =>
1979
+ makeVaultStub({
1980
+ memberships: [{ companyUid: "cmp_a" }],
1981
+ entityGet: (uid: string) =>
1982
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1983
+ listPersons: () => Promise.resolve([olderPerson]),
1984
+ }),
1985
+ sync: syncSpy,
1986
+ });
1987
+
1988
+ const code = await runRunner(["--companies"], deps);
1989
+ expect(code).toBe(0);
1990
+
1991
+ const personalCall = (syncSpy.mock.calls as Array<[SyncOptions]>).find(
1992
+ (c) => c[0].company?.startsWith("prs_"),
1993
+ );
1994
+ expect(personalCall).toBeDefined();
1995
+ const personalArgs = personalCall![0] as SyncOptions & {
1996
+ includeLocalCompanies?: boolean;
1997
+ };
1998
+ // Explicitly false — not "undefined" — because the runner always
1999
+ // computes the value from the env var, then spreads it for the
2000
+ // personal slot regardless of the value (allows downstream to know
2001
+ // the gate was evaluated, not just "caller forgot to set it").
2002
+ expect(personalArgs.includeLocalCompanies).toBe(false);
2003
+ });
2004
+
1641
2005
  it("F: shareFn paths for company slots stay [hqRoot/companies/{slug}] with no personalMode/journalSlug keys", async () => {
1642
2006
  const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
1643
2007
  const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
@@ -88,6 +88,8 @@ import type { ShareOptions, ShareResult } from "../cli/share.js";
88
88
  import type { ConflictStrategy } from "../cli/conflict.js";
89
89
  import type { UploadAuthor } from "../s3.js";
90
90
  import { collectAndSendTelemetry } from "../telemetry.js";
91
+ import { describeError } from "../lib/describe-error.js";
92
+ import { getOrCreateMachineId } from "../lib/machine-id.js";
91
93
  import {
92
94
  TreeWatcher,
93
95
  WatchPushDriver,
@@ -592,24 +594,17 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
592
594
  async function defaultCollectTelemetry(
593
595
  client: VaultClientSurface,
594
596
  clientIsStub: boolean,
597
+ hqRoot: string,
595
598
  ): Promise<void> {
596
599
  if (clientIsStub) return;
597
600
  try {
598
- // machineId: prefer ~/.hq/menubar.json (set by the menubar app on first
599
- // launch). When absent — e.g. fresh CLI-only install fall back to a
600
- // value that makes the row identifiable as "unattributed" rather than
601
- // crashing or spoofing another machine's id.
602
- const menubarPath = path.join(os.homedir(), ".hq", "menubar.json");
603
- let machineId = "unknown";
604
- try {
605
- const raw = await fs.promises.readFile(menubarPath, "utf-8");
606
- const parsed = JSON.parse(raw) as { machineId?: unknown };
607
- if (typeof parsed.machineId === "string" && parsed.machineId.length > 0) {
608
- machineId = parsed.machineId;
609
- }
610
- } catch {
611
- // No menubar.json — proceed with "unknown".
612
- }
601
+ // machineId: hq-cloud owns provisioning via `<hqRoot>/.hq/machine-id`
602
+ // (see `src/lib/machine-id.ts`). The resolver migrates forward from
603
+ // any legacy `~/.hq/menubar.json` value on first call, then becomes
604
+ // self-sufficient. On a clean Linux outpost (no menubar app), a fresh
605
+ // UUID is generated + persisted, so this row is attributable rather
606
+ // than collapsing onto the legacy `"unknown"` sentinel.
607
+ const machineId = getOrCreateMachineId(hqRoot);
613
608
 
614
609
  // installerVersion: callers (the Tauri menubar) set this when spawning
615
610
  // the runner so the historical `installerVersion` dimension on
@@ -973,11 +968,59 @@ export async function runRunner(
973
968
  // target walks every top-level entry under hqRoot minus the exclusion
974
969
  // list (see PERSONAL_VAULT_EXCLUDED_TOP_LEVEL). `skipUnchanged: true`
975
970
  // keeps both cases efficient on re-runs.
971
+ // Shared between push and pull for the personal slot. Hoisted out of
972
+ // the if-blocks below so doPush + doPull see the same set:
973
+ //
974
+ // `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1` opts INTO the
975
+ // cloud:false → personal-bucket fallback. Default OFF keeps the
976
+ // personal vault identical to the pre-5.20 shape until operators
977
+ // explicitly enable it.
978
+ //
979
+ // `teamSyncedSlugs` is the slug set the operator currently has
980
+ // active team-bucket Memberships for, derived from the live plan.
981
+ // Used by:
982
+ // • push: filter `companies/` subdir enumeration so a team-synced
983
+ // company never rides the personal slot; build
984
+ // `decommissionPrefixes` so share() sweeps orphan keys for
985
+ // any slug that's now team-backed.
986
+ // • pull: filter `listRemoteFiles` so pre-decommission orphan
987
+ // keys at `companies/{team-synced-slug}/...` don't get
988
+ // re-downloaded into the disk paths the team-bucket pull
989
+ // now manages.
990
+ const includeLocalCompanies =
991
+ process.env.HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL === "1";
992
+ const teamSyncedSlugs = new Set(
993
+ plan
994
+ .filter((p) => p.personalMode !== true)
995
+ .map((p) => p.slug),
996
+ );
997
+
976
998
  if (doPush) {
977
999
  activePhase = "push";
1000
+ // For the personal slot we hand share() both (a) the top-level
1001
+ // hqRoot entries that are part of the personal vault and (b) any
1002
+ // `companies/{slug}/` subdirs the user has opted into the personal
1003
+ // bucket via `cloud: false` in `company.yaml`. Slugs that already
1004
+ // own a team bucket (anything else in `plan`) are excluded so a
1005
+ // company is never double-written.
978
1006
  const pushPaths = target.personalMode === true
979
- ? computePersonalVaultPaths(parsed.hqRoot)
1007
+ ? computePersonalVaultPaths(parsed.hqRoot, {
1008
+ includeLocalCompanies,
1009
+ teamSyncedSlugs,
1010
+ })
980
1011
  : [path.join(parsed.hqRoot, "companies", target.slug)];
1012
+ // For the personal slot, hand share() a list of `companies/{slug}/`
1013
+ // prefixes for every team-synced slug. If the personal-bucket
1014
+ // journal still holds entries under any of those prefixes — i.e.
1015
+ // the company used to ride the personal-bucket fallback and was
1016
+ // since promoted to its own team bucket — share() will sweep
1017
+ // those orphans via DeleteObject + journal removal. Unconditional
1018
+ // (not gated on `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL`) so cleanup
1019
+ // still runs after an operator turns the feature off: the on-ramp
1020
+ // is opt-in but the off-ramp is always safe to traverse.
1021
+ const decommissionPrefixes = target.personalMode === true
1022
+ ? Array.from(teamSyncedSlugs).map((slug) => `companies/${slug}`)
1023
+ : undefined;
981
1024
  pushResult = await shareFn({
982
1025
  paths: pushPaths,
983
1026
  company: target.uid,
@@ -1007,6 +1050,9 @@ export async function runRunner(
1007
1050
  // in sync-runner.test.ts pins that contract).
1008
1051
  ...(target.personalMode !== undefined ? { personalMode: target.personalMode } : {}),
1009
1052
  ...(target.journalSlug !== undefined ? { journalSlug: target.journalSlug } : {}),
1053
+ ...(decommissionPrefixes && decommissionPrefixes.length > 0
1054
+ ? { decommissionPrefixes }
1055
+ : {}),
1010
1056
  });
1011
1057
  }
1012
1058
 
@@ -1022,6 +1068,22 @@ export async function runRunner(
1022
1068
  onConflict: parsed.onConflict,
1023
1069
  ...(target.personalMode !== undefined ? { personalMode: target.personalMode } : {}),
1024
1070
  ...(target.journalSlug !== undefined ? { journalSlug: target.journalSlug } : {}),
1071
+ // Symmetric to the push side: for the personal slot, tell sync()
1072
+ // how to interpret `companies/{slug}/...` keys in the personal
1073
+ // bucket. With `includeLocalCompanies: true`, keys for slugs NOT
1074
+ // in `teamSyncedSlugs` are downloaded (legitimate cloud:false
1075
+ // opt-in content from another machine); keys for slugs IN that
1076
+ // set are dropped as orphans (pre-decommission residue from a
1077
+ // promoted company). With `includeLocalCompanies: false` the
1078
+ // legacy pre-5.20 behavior holds: all `companies/...` keys are
1079
+ // dropped. Only spread for the personal slot so company-target
1080
+ // args stay identical to pre-Slice-2 shape (test C pin).
1081
+ ...(target.personalMode === true
1082
+ ? {
1083
+ includeLocalCompanies,
1084
+ teamSyncedSlugs,
1085
+ }
1086
+ : {}),
1025
1087
  onEvent: tagAndEmit,
1026
1088
  });
1027
1089
  }
@@ -1086,7 +1148,10 @@ export async function runRunner(
1086
1148
  allConflicts.push({ company: companyLabel, path: p, direction: "push" });
1087
1149
  }
1088
1150
  } catch (err) {
1089
- const message = err instanceof Error ? err.message : String(err);
1151
+ // describeError walks the cause chain so AWS SDK v3's "UnknownError"
1152
+ // wrapper surfaces the underlying Node networking error (ENOTFOUND,
1153
+ // ECONNRESET, …) instead of an unactionable bare "UnknownError".
1154
+ const message = describeError(err);
1090
1155
  errors.push({ company: companyLabel, message });
1091
1156
  // `state.status` was seeded as "errored" at loop entry — the throw
1092
1157
  // path leaves it there, and `state.files{Down,Up}loaded` reflects the
@@ -1174,7 +1239,7 @@ export async function runRunner(
1174
1239
  // which naturally bounds the outer wait.
1175
1240
  const telemetryFn =
1176
1241
  deps.collectTelemetry ??
1177
- (() => defaultCollectTelemetry(client, deps.createVaultClient !== undefined));
1242
+ (() => defaultCollectTelemetry(client, deps.createVaultClient !== undefined, parsed.hqRoot));
1178
1243
  await telemetryFn().catch(() => undefined);
1179
1244
 
1180
1245
  emit({