@indigoai-us/hq-cloud 5.24.0 → 5.25.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 +51 -16
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +67 -3
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +58 -15
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +9 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +54 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +6 -3
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +21 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/personal-vault-exclusions.d.ts +128 -0
- package/dist/personal-vault-exclusions.d.ts.map +1 -0
- package/dist/personal-vault-exclusions.js +231 -0
- package/dist/personal-vault-exclusions.js.map +1 -0
- package/dist/personal-vault-exclusions.test.d.ts +22 -0
- package/dist/personal-vault-exclusions.test.d.ts.map +1 -0
- package/dist/personal-vault-exclusions.test.js +198 -0
- package/dist/personal-vault-exclusions.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +71 -15
- package/src/bin/sync-runner.ts +100 -19
- package/src/cli/share.test.ts +8 -3
- package/src/cli/share.ts +66 -1
- package/src/cli/sync.ts +22 -0
- package/src/index.ts +10 -0
- package/src/personal-vault-exclusions.test.ts +256 -0
- package/src/personal-vault-exclusions.ts +277 -0
|
@@ -11,7 +11,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
11
11
|
import * as fs from "fs";
|
|
12
12
|
import * as os from "os";
|
|
13
13
|
import * as path from "path";
|
|
14
|
-
import { runRunner, resolveDeletePolicy } from "./sync-runner.js";
|
|
14
|
+
import { runRunner, resolveDeletePolicy, resolveSkipPersonal } from "./sync-runner.js";
|
|
15
15
|
import type {
|
|
16
16
|
RunnerEvent,
|
|
17
17
|
RunnerDeps,
|
|
@@ -80,6 +80,7 @@ function defaultShareResult(overrides: Partial<ShareResult> = {}): ShareResult {
|
|
|
80
80
|
filesDeleted: 0,
|
|
81
81
|
filesTombstoned: 0,
|
|
82
82
|
filesRefusedStale: 0,
|
|
83
|
+
filesExcludedByPolicy: 0,
|
|
83
84
|
conflictPaths: [],
|
|
84
85
|
aborted: false,
|
|
85
86
|
...overrides,
|
|
@@ -687,7 +688,9 @@ describe("per-company fanout", () => {
|
|
|
687
688
|
const complete = deps.stdout
|
|
688
689
|
.events()
|
|
689
690
|
.find((e) => e.type === "complete") as Extract<RunnerEvent, { type: "complete" }>;
|
|
690
|
-
// Pull-only run: upload counters are 0.
|
|
691
|
+
// Pull-only run: upload counters are 0. Push-side counters added in
|
|
692
|
+
// 5.25 (filesTombstoned/filesRefusedStale/filesExcludedByPolicy) are
|
|
693
|
+
// also 0 because no push leg ran.
|
|
691
694
|
expect(complete).toEqual({
|
|
692
695
|
type: "complete",
|
|
693
696
|
company: "acme",
|
|
@@ -699,6 +702,9 @@ describe("per-company fanout", () => {
|
|
|
699
702
|
aborted: result.aborted,
|
|
700
703
|
filesUploaded: 0,
|
|
701
704
|
bytesUploaded: 0,
|
|
705
|
+
filesTombstoned: 0,
|
|
706
|
+
filesRefusedStale: 0,
|
|
707
|
+
filesExcludedByPolicy: 0,
|
|
702
708
|
newFiles: result.newFiles,
|
|
703
709
|
newFilesCount: result.newFilesCount,
|
|
704
710
|
});
|
|
@@ -1709,11 +1715,11 @@ beforeEach(() => {
|
|
|
1709
1715
|
// ── resolveDeletePolicy: env-var contract ───────────────────────────────────
|
|
1710
1716
|
//
|
|
1711
1717
|
// `HQ_SYNC_DELETE_POLICY` is the documented rollback knob for the
|
|
1712
|
-
// currency-gated default
|
|
1713
|
-
// allowlist + default so every callsite (both
|
|
1714
|
-
// paths in the runner) gets identical
|
|
1715
|
-
//
|
|
1716
|
-
// instead of in the field.
|
|
1718
|
+
// currency-gated default that became default in 5.25 (after a 5.24 soak).
|
|
1719
|
+
// The helper centralizes the allowlist + default so every callsite (both
|
|
1720
|
+
// the personal and company push paths in the runner) gets identical
|
|
1721
|
+
// semantics. Tests pin the four expected behaviors so a future regression
|
|
1722
|
+
// on the allowlist or default surfaces here instead of in the field.
|
|
1717
1723
|
|
|
1718
1724
|
describe("resolveDeletePolicy", () => {
|
|
1719
1725
|
let originalEnv: string | undefined;
|
|
@@ -1731,12 +1737,11 @@ describe("resolveDeletePolicy", () => {
|
|
|
1731
1737
|
}
|
|
1732
1738
|
});
|
|
1733
1739
|
|
|
1734
|
-
it("defaults to '
|
|
1735
|
-
// 5.24
|
|
1736
|
-
//
|
|
1737
|
-
//
|
|
1738
|
-
|
|
1739
|
-
expect(resolveDeletePolicy()).toBe("owned-only");
|
|
1740
|
+
it("defaults to 'currency-gated' in 5.25 (post-soak default flip)", () => {
|
|
1741
|
+
// 5.24 shipped the code path with `owned-only` as default; 5.25 flips
|
|
1742
|
+
// the default to `currency-gated` after the soak window. Rollback knob
|
|
1743
|
+
// is `HQ_SYNC_DELETE_POLICY=owned-only` for anyone surprised.
|
|
1744
|
+
expect(resolveDeletePolicy()).toBe("currency-gated");
|
|
1740
1745
|
});
|
|
1741
1746
|
|
|
1742
1747
|
it.each(["currency-gated", "owned-only", "all"] as const)(
|
|
@@ -1749,11 +1754,62 @@ describe("resolveDeletePolicy", () => {
|
|
|
1749
1754
|
|
|
1750
1755
|
it("falls back to default on unknown env values (no silent corruption)", () => {
|
|
1751
1756
|
process.env.HQ_SYNC_DELETE_POLICY = "yolo";
|
|
1752
|
-
expect(resolveDeletePolicy()).toBe("
|
|
1757
|
+
expect(resolveDeletePolicy()).toBe("currency-gated");
|
|
1753
1758
|
});
|
|
1754
1759
|
|
|
1755
1760
|
it("treats empty string as unset → default", () => {
|
|
1756
1761
|
process.env.HQ_SYNC_DELETE_POLICY = "";
|
|
1757
|
-
expect(resolveDeletePolicy()).toBe("
|
|
1762
|
+
expect(resolveDeletePolicy()).toBe("currency-gated");
|
|
1758
1763
|
});
|
|
1759
1764
|
});
|
|
1765
|
+
|
|
1766
|
+
// ── resolveSkipPersonal: flag-OR-env combination ───────────────────────────
|
|
1767
|
+
//
|
|
1768
|
+
// Two inputs combine: the `--skip-personal` CLI flag and the
|
|
1769
|
+
// `HQ_SYNC_SKIP_PERSONAL` env var. Either being truthy skips personal sync.
|
|
1770
|
+
// The flag is the explicit-for-this-invocation knob (menubar passes it when
|
|
1771
|
+
// the user toggled "Sync personal vault" off); the env is the persistent
|
|
1772
|
+
// child-process default. Both surfaces tested so a regression on either
|
|
1773
|
+
// short-circuit path surfaces here.
|
|
1774
|
+
|
|
1775
|
+
describe("resolveSkipPersonal", () => {
|
|
1776
|
+
let originalEnv: string | undefined;
|
|
1777
|
+
|
|
1778
|
+
beforeEach(() => {
|
|
1779
|
+
originalEnv = process.env.HQ_SYNC_SKIP_PERSONAL;
|
|
1780
|
+
delete process.env.HQ_SYNC_SKIP_PERSONAL;
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
afterEach(() => {
|
|
1784
|
+
if (originalEnv === undefined) {
|
|
1785
|
+
delete process.env.HQ_SYNC_SKIP_PERSONAL;
|
|
1786
|
+
} else {
|
|
1787
|
+
process.env.HQ_SYNC_SKIP_PERSONAL = originalEnv;
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
it("defaults to false (personal sync enabled, current behavior)", () => {
|
|
1792
|
+
expect(resolveSkipPersonal(false)).toBe(false);
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
it("flag=true short-circuits to true regardless of env", () => {
|
|
1796
|
+
process.env.HQ_SYNC_SKIP_PERSONAL = "no"; // explicit "no" in env
|
|
1797
|
+
expect(resolveSkipPersonal(true)).toBe(true);
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
it.each(["1", "true", "yes", "TRUE", "Yes"])(
|
|
1801
|
+
"env value '%s' (truthy) -> true",
|
|
1802
|
+
(val) => {
|
|
1803
|
+
process.env.HQ_SYNC_SKIP_PERSONAL = val;
|
|
1804
|
+
expect(resolveSkipPersonal(false)).toBe(true);
|
|
1805
|
+
},
|
|
1806
|
+
);
|
|
1807
|
+
|
|
1808
|
+
it.each(["0", "false", "no", "", "unset-equiv"])(
|
|
1809
|
+
"env value '%s' (falsy) -> false",
|
|
1810
|
+
(val) => {
|
|
1811
|
+
process.env.HQ_SYNC_SKIP_PERSONAL = val;
|
|
1812
|
+
expect(resolveSkipPersonal(false)).toBe(false);
|
|
1813
|
+
},
|
|
1814
|
+
);
|
|
1815
|
+
});
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* --company <slug-or-uid> Sync a single company (alternative to --companies)
|
|
15
15
|
* --on-conflict <strategy> abort | overwrite | keep (default: abort)
|
|
16
16
|
* --hq-root <path> Local HQ directory (default: $HOME/hq)
|
|
17
|
+
* --skip-personal Drop the personal target from the --companies
|
|
18
|
+
* fanout. Combined with HQ_SYNC_SKIP_PERSONAL env
|
|
19
|
+
* (either truthy disables personal sync). No-op
|
|
20
|
+
* outside --companies mode.
|
|
17
21
|
* --json Ignored — ndjson on stdout is the default and
|
|
18
22
|
* only output mode. Accepted for symmetry with the
|
|
19
23
|
* AppBar's argv in case someone passes it.
|
|
@@ -122,21 +126,24 @@ const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
|
|
|
122
126
|
|
|
123
127
|
/**
|
|
124
128
|
* Delete-propagation policy honored by the push leg of bidirectional sync.
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
129
|
+
*
|
|
130
|
+
* Default `"currency-gated"` in 5.25 — flipped from `"owned-only"` after
|
|
131
|
+
* one machine (Indigo / corey) ran the 5.24 code path through real syncs
|
|
132
|
+
* for a week without surfacing surprise behavior. Currency-gated does a
|
|
133
|
+
* per-file ETag HEAD before propagating any local-delete to S3: if the
|
|
134
|
+
* remote object's current ETag no longer matches the journal's last-
|
|
135
|
+
* recorded one, the delete is refused and the next pull leg re-pulls the
|
|
136
|
+
* file via the standard 3-way merge path. This is strictly safer than
|
|
137
|
+
* `owned-only` (which propagates any local-delete the journal can prove
|
|
138
|
+
* came from this device) — the only delete-class that changes behavior
|
|
139
|
+
* is "deleted locally + modified remotely by another device", which
|
|
140
|
+
* previously destroyed remote work and now becomes a pull-and-conflict.
|
|
133
141
|
*
|
|
134
142
|
* Env override `HQ_SYNC_DELETE_POLICY=owned-only|all|currency-gated` is
|
|
135
|
-
* also the rollback knob —
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* recommended default.
|
|
143
|
+
* also the rollback knob — anyone surprised by 5.25's flip can revert
|
|
144
|
+
* to `owned-only` without redeploying. `all` is the unsafe-mirror mode
|
|
145
|
+
* previously used by the runner pre-5.20 — included only as an
|
|
146
|
+
* emergency reconcile lever, not a recommended default.
|
|
140
147
|
*/
|
|
141
148
|
export type DeletePropagationPolicy = "currency-gated" | "owned-only" | "all";
|
|
142
149
|
|
|
@@ -145,7 +152,33 @@ export function resolveDeletePolicy(): DeletePropagationPolicy {
|
|
|
145
152
|
if (env === "owned-only" || env === "all" || env === "currency-gated") {
|
|
146
153
|
return env;
|
|
147
154
|
}
|
|
148
|
-
return "
|
|
155
|
+
return "currency-gated";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve whether to skip the personal target in a `--companies` fanout.
|
|
160
|
+
*
|
|
161
|
+
* Two inputs combine: the `--skip-personal` CLI flag (parsed into
|
|
162
|
+
* `ParsedArgs.skipPersonal`) and the `HQ_SYNC_SKIP_PERSONAL` env var. Either
|
|
163
|
+
* being truthy skips the personal target — flag wins on conflict (CLI
|
|
164
|
+
* flag is the explicit-for-this-invocation knob, env is the persistent
|
|
165
|
+
* default usually set by the menubar in the spawned child process).
|
|
166
|
+
*
|
|
167
|
+
* Env truthy values: `1`, `true`, `yes` (case-insensitive). Anything else
|
|
168
|
+
* (including missing) is treated as falsy — same shape as classic
|
|
169
|
+
* Unix opt-in env conventions; conservative to avoid surprising opt-outs.
|
|
170
|
+
*
|
|
171
|
+
* Use case: the menubar app exposes a "Sync personal vault" toggle in
|
|
172
|
+
* Settings (default ON, matching the auto-provisioning UX). When the user
|
|
173
|
+
* flips it off, the menubar spawns `hq sync` with this env set so the
|
|
174
|
+
* fanout drops the personal target before walking the user's entire HQ
|
|
175
|
+
* tree (a sync that would otherwise scan thousands of files, including
|
|
176
|
+
* the new personal-vault default exclusions, just to do nothing useful).
|
|
177
|
+
*/
|
|
178
|
+
export function resolveSkipPersonal(flag: boolean): boolean {
|
|
179
|
+
if (flag) return true;
|
|
180
|
+
const env = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
|
|
181
|
+
return env === "1" || env === "true" || env === "yes";
|
|
149
182
|
}
|
|
150
183
|
|
|
151
184
|
// Personal-vault scope (exclusion list + path computer) lives in
|
|
@@ -197,12 +230,19 @@ export type RunnerEvent =
|
|
|
197
230
|
/**
|
|
198
231
|
* Upload counters. Always emitted (0 when the run was pull-only) so
|
|
199
232
|
* downstream consumers don't need to conditionally read the field.
|
|
200
|
-
* Tauri's `SyncCompleteEvent` ignores extra fields today; adding them
|
|
201
|
-
* to the Rust struct is a follow-up when the UI needs to surface push
|
|
202
|
-
* totals.
|
|
203
233
|
*/
|
|
204
234
|
filesUploaded: number;
|
|
205
235
|
bytesUploaded: number;
|
|
236
|
+
/**
|
|
237
|
+
* Push-side counters added in 5.25. Always emitted as numbers (0
|
|
238
|
+
* when no push leg ran). Tauri's `SyncCompleteEvent` carries them
|
|
239
|
+
* as Option<u32> for back-compat with <5.25 engines that don't
|
|
240
|
+
* include them; structural-typing-wise, the union just adds
|
|
241
|
+
* properties on top of `SyncResult`.
|
|
242
|
+
*/
|
|
243
|
+
filesTombstoned: number;
|
|
244
|
+
filesRefusedStale: number;
|
|
245
|
+
filesExcludedByPolicy: number;
|
|
206
246
|
} & SyncResult)
|
|
207
247
|
| {
|
|
208
248
|
type: "all-complete";
|
|
@@ -402,6 +442,13 @@ interface ParsedArgs {
|
|
|
402
442
|
watch: boolean;
|
|
403
443
|
/** Auto-sync (Beta): ms between remote-pull passes. Required when watch=true. */
|
|
404
444
|
pollRemoteMs?: number;
|
|
445
|
+
/**
|
|
446
|
+
* Drop the personal target from the fanout. Combined with the
|
|
447
|
+
* `HQ_SYNC_SKIP_PERSONAL` env var by `resolveSkipPersonal()` — either
|
|
448
|
+
* truthy disables personal sync for this run. No-op outside `--companies`
|
|
449
|
+
* mode (single-company runs never visit the personal target).
|
|
450
|
+
*/
|
|
451
|
+
skipPersonal: boolean;
|
|
405
452
|
}
|
|
406
453
|
|
|
407
454
|
function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
@@ -412,6 +459,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
412
459
|
let direction: Direction = "pull";
|
|
413
460
|
let watch = false;
|
|
414
461
|
let pollRemoteMs: number | undefined;
|
|
462
|
+
let skipPersonal = false;
|
|
415
463
|
|
|
416
464
|
for (let i = 0; i < argv.length; i++) {
|
|
417
465
|
const arg = argv[i];
|
|
@@ -465,6 +513,12 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
465
513
|
case "--json":
|
|
466
514
|
// Accepted but ignored — ndjson is the only output mode.
|
|
467
515
|
break;
|
|
516
|
+
case "--skip-personal":
|
|
517
|
+
// Drop the personal target from the fanout. No-op outside
|
|
518
|
+
// --companies mode. Combined with HQ_SYNC_SKIP_PERSONAL env via
|
|
519
|
+
// resolveSkipPersonal().
|
|
520
|
+
skipPersonal = true;
|
|
521
|
+
break;
|
|
468
522
|
default:
|
|
469
523
|
return { error: `Unknown argument: ${arg}` };
|
|
470
524
|
}
|
|
@@ -480,7 +534,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
480
534
|
return { error: "--poll-remote-ms requires --watch" };
|
|
481
535
|
}
|
|
482
536
|
|
|
483
|
-
return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs };
|
|
537
|
+
return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs, skipPersonal };
|
|
484
538
|
}
|
|
485
539
|
|
|
486
540
|
// ---------------------------------------------------------------------------
|
|
@@ -698,7 +752,15 @@ export async function runRunner(
|
|
|
698
752
|
plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
|
|
699
753
|
}
|
|
700
754
|
|
|
701
|
-
if (parsed.companies) {
|
|
755
|
+
if (parsed.companies && !resolveSkipPersonal(parsed.skipPersonal)) {
|
|
756
|
+
// Personal-target fanout slot. Skipped entirely when --skip-personal
|
|
757
|
+
// (or HQ_SYNC_SKIP_PERSONAL=1) is set — see resolveSkipPersonal doc for
|
|
758
|
+
// the rationale (menubar opt-out for users who only want company sync).
|
|
759
|
+
// When skipped, the fanout-plan event below carries only company
|
|
760
|
+
// memberships and no "personal" slug; downstream consumers (menubar
|
|
761
|
+
// workspaces row, status surfaces) should already tolerate that
|
|
762
|
+
// shape since pre-5.25 fanout often had it (a user with no person
|
|
763
|
+
// entity yet, or before the canonical-person-entity machinery landed).
|
|
702
764
|
const persons = await client.entity.listByType("person");
|
|
703
765
|
const pick = pickCanonicalPersonEntity(persons);
|
|
704
766
|
if (pick?.bucketName) {
|
|
@@ -829,6 +891,7 @@ export async function runRunner(
|
|
|
829
891
|
filesDeleted: 0,
|
|
830
892
|
filesTombstoned: 0,
|
|
831
893
|
filesRefusedStale: 0,
|
|
894
|
+
filesExcludedByPolicy: 0,
|
|
832
895
|
conflictPaths: [],
|
|
833
896
|
aborted: false,
|
|
834
897
|
};
|
|
@@ -930,6 +993,17 @@ export async function runRunner(
|
|
|
930
993
|
filesUploaded: pushResult.filesUploaded,
|
|
931
994
|
bytesUploaded: pushResult.bytesUploaded,
|
|
932
995
|
filesSkipped: pullResult.filesSkipped + pushResult.filesSkipped,
|
|
996
|
+
// Push-side counters surfaced on `complete` so the menubar's
|
|
997
|
+
// `SyncCompleteEvent` (which carries them as Option<u32> for
|
|
998
|
+
// back-compat with pre-5.25 engines) can render the new totals.
|
|
999
|
+
// Always emitted as numbers (0 when no push leg ran) so Rust's
|
|
1000
|
+
// serde decodes them as `Some(0)` rather than `None` — distinct
|
|
1001
|
+
// from the legacy-engine `None` and useful when the UI wants to
|
|
1002
|
+
// distinguish "engine ran, nothing tombstoned" from "engine
|
|
1003
|
+
// didn't report".
|
|
1004
|
+
filesTombstoned: pushResult.filesTombstoned,
|
|
1005
|
+
filesRefusedStale: pushResult.filesRefusedStale,
|
|
1006
|
+
filesExcludedByPolicy: pushResult.filesExcludedByPolicy,
|
|
933
1007
|
// Sourced from the merged path list so push-side conflicts are
|
|
934
1008
|
// counted too — `ShareResult` doesn't expose a numeric counter,
|
|
935
1009
|
// and using `pullResult.conflicts` alone silently dropped any
|
|
@@ -967,6 +1041,13 @@ export async function runRunner(
|
|
|
967
1041
|
filesUploaded: state.filesUploaded,
|
|
968
1042
|
bytesUploaded: state.bytesUploaded,
|
|
969
1043
|
filesSkipped: 0,
|
|
1044
|
+
// Mid-flight throw: we have no clean ShareResult to read these
|
|
1045
|
+
// from. Report 0 so the event shape stays stable; the partial
|
|
1046
|
+
// counts above already reflect what actually moved before the
|
|
1047
|
+
// throw.
|
|
1048
|
+
filesTombstoned: 0,
|
|
1049
|
+
filesRefusedStale: 0,
|
|
1050
|
+
filesExcludedByPolicy: 0,
|
|
970
1051
|
conflicts: 0,
|
|
971
1052
|
conflictPaths: [],
|
|
972
1053
|
aborted: true,
|
package/src/cli/share.test.ts
CHANGED
|
@@ -595,9 +595,14 @@ describe("share", () => {
|
|
|
595
595
|
vaultConfig: mockConfig,
|
|
596
596
|
hqRoot: tmpDir,
|
|
597
597
|
onEvent: (e) => {
|
|
598
|
-
// Only file-level events carry `.path`. The Stage-1 `plan` event
|
|
599
|
-
//
|
|
600
|
-
|
|
598
|
+
// Only file-level events carry `.path`. The Stage-1 `plan` event +
|
|
599
|
+
// the new-files event + the personal-vault-out-of-policy summary
|
|
600
|
+
// event are surfaced separately and tested in their own blocks.
|
|
601
|
+
if (
|
|
602
|
+
e.type === "plan" ||
|
|
603
|
+
e.type === "new-files" ||
|
|
604
|
+
e.type === "personal-vault-out-of-policy"
|
|
605
|
+
) return;
|
|
601
606
|
events.push({
|
|
602
607
|
type: e.type,
|
|
603
608
|
path: e.path,
|
package/src/cli/share.ts
CHANGED
|
@@ -21,6 +21,10 @@ import {
|
|
|
21
21
|
normalizeEtag,
|
|
22
22
|
} from "../journal.js";
|
|
23
23
|
import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
|
|
24
|
+
import {
|
|
25
|
+
wrapFilterWithPersonalVaultDefaults,
|
|
26
|
+
type PersonalVaultExclusion,
|
|
27
|
+
} from "../personal-vault-exclusions.js";
|
|
24
28
|
import { resolveConflict } from "./conflict.js";
|
|
25
29
|
import type { ConflictStrategy } from "./conflict.js";
|
|
26
30
|
import type { SyncProgressEvent } from "./sync.js";
|
|
@@ -386,6 +390,15 @@ export interface ShareResult {
|
|
|
386
390
|
* `currency-gated`.
|
|
387
391
|
*/
|
|
388
392
|
filesRefusedStale: number;
|
|
393
|
+
/**
|
|
394
|
+
* Number of paths blocked by `PERSONAL_VAULT_DEFAULT_EXCLUSIONS` during this
|
|
395
|
+
* run (push leg, personalMode=true). Includes both files that would have
|
|
396
|
+
* uploaded and journal entries that would have been included in the delete
|
|
397
|
+
* plan; deduplicated across walks. Always 0 outside personalMode. Mirrors
|
|
398
|
+
* the `count` field of the `personal-vault-out-of-policy` event (which is
|
|
399
|
+
* emitted exactly once if this is > 0).
|
|
400
|
+
*/
|
|
401
|
+
filesExcludedByPolicy: number;
|
|
389
402
|
/**
|
|
390
403
|
* Paths (company-relative) that were detected as push conflicts. Mirrors
|
|
391
404
|
* `SyncResult.conflictPaths` so push and pull surface conflicts the same
|
|
@@ -463,7 +476,34 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
463
476
|
const syncRoot = options.personalMode === true
|
|
464
477
|
? hqRoot
|
|
465
478
|
: path.join(hqRoot, "companies", ctx.slug);
|
|
466
|
-
|
|
479
|
+
|
|
480
|
+
// Personal-vault default exclusions (introduced in 5.25): wrap the base
|
|
481
|
+
// ignore filter so paths matching `PERSONAL_VAULT_DEFAULT_EXCLUSIONS` are
|
|
482
|
+
// rejected before they upload OR enter the delete plan. Refuses & warns —
|
|
483
|
+
// an already-leaked remote object stays put as an orphan; a separate one-
|
|
484
|
+
// shot purge handles legacy litter.
|
|
485
|
+
//
|
|
486
|
+
// Out-of-policy hits are deduplicated in `excludedSet` so the same path
|
|
487
|
+
// hitting the filter from both the upload walk and the delete-plan walk
|
|
488
|
+
// counts once. `excludedById` powers the per-rule breakdown on the
|
|
489
|
+
// `personal-vault-out-of-policy` event so UI can render which class
|
|
490
|
+
// (secret / machine-local / scratch / …) did the work.
|
|
491
|
+
//
|
|
492
|
+
// Company-mode syncs skip this wrap entirely — company vaults have their
|
|
493
|
+
// own first-push protection (settings/, data/, workers/, .git/) defined
|
|
494
|
+
// in hq-sync's Rust util/ignore.rs, and a company may legitimately ship
|
|
495
|
+
// `output/` or `.env*` paths inside its `companies/{slug}/data/` folder.
|
|
496
|
+
const ignoreFilter = createIgnoreFilter(hqRoot);
|
|
497
|
+
const excludedSet = new Set<string>();
|
|
498
|
+
const excludedById: Record<string, number> = {};
|
|
499
|
+
const onExcluded = (rel: string, match: PersonalVaultExclusion) => {
|
|
500
|
+
if (excludedSet.has(rel)) return;
|
|
501
|
+
excludedSet.add(rel);
|
|
502
|
+
excludedById[match.id] = (excludedById[match.id] ?? 0) + 1;
|
|
503
|
+
};
|
|
504
|
+
const shouldSync = options.personalMode === true
|
|
505
|
+
? wrapFilterWithPersonalVaultDefaults(ignoreFilter, syncRoot, onExcluded)
|
|
506
|
+
: ignoreFilter;
|
|
467
507
|
const journalSlug = options.journalSlug ?? ctx.slug;
|
|
468
508
|
const journal = readJournal(journalSlug);
|
|
469
509
|
|
|
@@ -599,6 +639,12 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
599
639
|
// ShareResult shape stable for consumers that destructure.
|
|
600
640
|
filesTombstoned,
|
|
601
641
|
filesRefusedStale,
|
|
642
|
+
// Exclusions are computed during the upload walk which has
|
|
643
|
+
// already completed by the time we hit a per-file conflict-
|
|
644
|
+
// abort, so the count is meaningful here. No event emit on
|
|
645
|
+
// abort (matches the existing convention: abort short-circuits
|
|
646
|
+
// before the end-of-run telemetry emits).
|
|
647
|
+
filesExcludedByPolicy: excludedSet.size,
|
|
602
648
|
conflictPaths,
|
|
603
649
|
aborted: true,
|
|
604
650
|
};
|
|
@@ -727,6 +773,24 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
727
773
|
journal.lastSync = new Date().toISOString();
|
|
728
774
|
writeJournal(journalSlug, journal);
|
|
729
775
|
|
|
776
|
+
// Personal-vault out-of-policy summary. Emit at most once, only when at
|
|
777
|
+
// least one path was excluded. Sample is capped at 10 to keep the event
|
|
778
|
+
// small (Set iteration order = insertion order, so samples are the first
|
|
779
|
+
// ten paths encountered during the walk — deterministic, not random).
|
|
780
|
+
if (excludedSet.size > 0) {
|
|
781
|
+
const samplePaths: string[] = [];
|
|
782
|
+
for (const p of excludedSet) {
|
|
783
|
+
samplePaths.push(p);
|
|
784
|
+
if (samplePaths.length >= 10) break;
|
|
785
|
+
}
|
|
786
|
+
emit({
|
|
787
|
+
type: "personal-vault-out-of-policy",
|
|
788
|
+
count: excludedSet.size,
|
|
789
|
+
samplePaths,
|
|
790
|
+
byId: { ...excludedById },
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
730
794
|
return {
|
|
731
795
|
filesUploaded,
|
|
732
796
|
bytesUploaded,
|
|
@@ -734,6 +798,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
734
798
|
filesDeleted,
|
|
735
799
|
filesTombstoned,
|
|
736
800
|
filesRefusedStale,
|
|
801
|
+
filesExcludedByPolicy: excludedSet.size,
|
|
737
802
|
conflictPaths,
|
|
738
803
|
aborted: false,
|
|
739
804
|
};
|
package/src/cli/sync.ts
CHANGED
|
@@ -120,6 +120,28 @@ export type SyncProgressEvent =
|
|
|
120
120
|
journalEtag: string;
|
|
121
121
|
remoteEtag: string;
|
|
122
122
|
reason: "stale-etag" | "legacy-no-etag";
|
|
123
|
+
}
|
|
124
|
+
| {
|
|
125
|
+
/**
|
|
126
|
+
* Emitted at most ONCE per `share()` call (push leg of a sync run) when
|
|
127
|
+
* `personalMode === true` and the personal-vault default-exclusion list
|
|
128
|
+
* blocked one or more files that would otherwise have uploaded. Gives
|
|
129
|
+
* the UI a single summary signal — "N files quietly excluded by default
|
|
130
|
+
* policy" — without firing one event per excluded file (which would
|
|
131
|
+
* dominate the event stream on first-sync of a dirty tree).
|
|
132
|
+
*
|
|
133
|
+
* `count` is the total number of paths the exclusion filter rejected
|
|
134
|
+
* (deduplicated across the walk). `samplePaths` carries up to 10
|
|
135
|
+
* forward-slash-separated relative paths for diagnostic display. `byId`
|
|
136
|
+
* is a per-exclusion-rule breakdown so the UI can render which class
|
|
137
|
+
* of exclusion did the work (secret / machine-local / scratch / …).
|
|
138
|
+
*
|
|
139
|
+
* Not emitted when `count === 0` — silent on a clean tree.
|
|
140
|
+
*/
|
|
141
|
+
type: "personal-vault-out-of-policy";
|
|
142
|
+
count: number;
|
|
143
|
+
samplePaths: string[];
|
|
144
|
+
byId: Record<string, number>;
|
|
123
145
|
};
|
|
124
146
|
|
|
125
147
|
export interface SyncOptions {
|
package/src/index.ts
CHANGED
|
@@ -108,6 +108,16 @@ export {
|
|
|
108
108
|
computePersonalVaultPaths,
|
|
109
109
|
} from "./personal-vault.js";
|
|
110
110
|
|
|
111
|
+
// Personal-vault default-exclusions (5.25+) — second-tier deep-walk filter
|
|
112
|
+
// for secrets, machine-local state, scratch dirs, OS/build cruft.
|
|
113
|
+
export {
|
|
114
|
+
PERSONAL_VAULT_DEFAULT_EXCLUSIONS,
|
|
115
|
+
isPersonalVaultExcluded,
|
|
116
|
+
matchPersonalVaultExclusion,
|
|
117
|
+
wrapFilterWithPersonalVaultDefaults,
|
|
118
|
+
} from "./personal-vault-exclusions.js";
|
|
119
|
+
export type { PersonalVaultExclusion } from "./personal-vault-exclusions.js";
|
|
120
|
+
|
|
111
121
|
// VaultClient SDK (VLT-7)
|
|
112
122
|
export { VaultClient, pickCanonicalPersonEntity } from "./vault-client.js";
|
|
113
123
|
export {
|