@indigoai-us/hq-cloud 6.14.18 → 6.14.20
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 +35 -0
- package/dist/bin/sync-runner-company.d.ts +7 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +21 -1
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +8 -16
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.js +138 -0
- package/dist/bin/sync-runner-planning.test.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts +10 -2
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +201 -25
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +14 -2
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -2
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +36 -6
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +550 -3
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-core.js +27 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-drop-dir-symlink.test.d.ts +2 -0
- package/dist/cli/rescue-drop-dir-symlink.test.d.ts.map +1 -0
- package/dist/cli/rescue-drop-dir-symlink.test.js +206 -0
- package/dist/cli/rescue-drop-dir-symlink.test.js.map +1 -0
- package/dist/cli/share.d.ts +15 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +100 -55
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +119 -6
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +19 -25
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +121 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +3 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +13 -2
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +26 -1
- package/dist/journal.test.js.map +1 -1
- package/dist/personal-vault.d.ts +12 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +20 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/qmd-reindex.d.ts +88 -11
- package/dist/qmd-reindex.d.ts.map +1 -1
- package/dist/qmd-reindex.js +354 -65
- package/dist/qmd-reindex.js.map +1 -1
- package/dist/qmd-reindex.test.d.ts +4 -0
- package/dist/qmd-reindex.test.d.ts.map +1 -1
- package/dist/qmd-reindex.test.js +285 -3
- package/dist/qmd-reindex.test.js.map +1 -1
- package/dist/s3.d.ts +13 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +34 -1
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +60 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/telemetry.d.ts +22 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +79 -0
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +117 -1
- package/dist/telemetry.test.js.map +1 -1
- package/dist/watcher.d.ts +26 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +75 -12
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-company.ts +29 -1
- package/src/bin/sync-runner-planning.test.ts +153 -0
- package/src/bin/sync-runner-planning.ts +10 -17
- package/src/bin/sync-runner-watch-loop.ts +322 -32
- package/src/bin/sync-runner-watch-routes.ts +14 -1
- package/src/bin/sync-runner.test.ts +662 -6
- package/src/bin/sync-runner.ts +65 -7
- package/src/cli/rescue-core.ts +26 -1
- package/src/cli/rescue-drop-dir-symlink.test.ts +224 -0
- package/src/cli/share.test.ts +150 -6
- package/src/cli/share.ts +143 -48
- package/src/cli/sync.test.ts +150 -1
- package/src/cli/sync.ts +25 -29
- package/src/journal.test.ts +45 -0
- package/src/journal.ts +18 -1
- package/src/personal-vault.ts +23 -0
- package/src/qmd-reindex.test.ts +324 -4
- package/src/qmd-reindex.ts +414 -76
- package/src/s3.test.ts +79 -0
- package/src/s3.ts +48 -1
- package/src/telemetry.test.ts +128 -0
- package/src/telemetry.ts +80 -0
- package/src/watcher.ts +111 -14
- package/test/e2e/watcher-real-chokidar.test.ts +62 -2
- package/test/e2e/watcher-recursive-backend.test.ts +67 -1
package/src/bin/sync-runner.ts
CHANGED
|
@@ -125,7 +125,11 @@ import {
|
|
|
125
125
|
} from "../lib/net-errors.js";
|
|
126
126
|
import { PARTIAL_SYNC_EXIT } from "../lib/exit-codes.js";
|
|
127
127
|
import { describeError } from "../lib/describe-error.js";
|
|
128
|
-
import type {
|
|
128
|
+
import type {
|
|
129
|
+
Clock,
|
|
130
|
+
LocalDeleteSnapshot,
|
|
131
|
+
TreeChangeBatch,
|
|
132
|
+
} from "../watcher.js";
|
|
129
133
|
import type { PushReceiver, SyncEngineFn } from "../sync/push-receiver.js";
|
|
130
134
|
import {
|
|
131
135
|
type EventSyncHandles,
|
|
@@ -664,6 +668,8 @@ export interface RunnerDeps {
|
|
|
664
668
|
operationLockAlreadyHeld?: boolean;
|
|
665
669
|
/** Internal watch-loop result override; one-shot callers keep exit 0. */
|
|
666
670
|
authRequiredExitCode?: number;
|
|
671
|
+
/** Internal watch-loop callback carrying uncapped per-path push outcomes. */
|
|
672
|
+
onPassResult?: (result: RunnerPassResult) => void;
|
|
667
673
|
/** Share function (push phase). Defaults to `cli/share.share`. */
|
|
668
674
|
share?: (options: ShareOptions) => Promise<ShareResult>;
|
|
669
675
|
/**
|
|
@@ -679,6 +685,20 @@ export interface RunnerDeps {
|
|
|
679
685
|
telemetryTimeoutMs?: number;
|
|
680
686
|
}
|
|
681
687
|
|
|
688
|
+
export interface RunnerPassResult {
|
|
689
|
+
pushPathResults: Array<{
|
|
690
|
+
relativePath: string;
|
|
691
|
+
status: "accepted" | "refused";
|
|
692
|
+
operation: "delete" | "tombstone";
|
|
693
|
+
reason?: string;
|
|
694
|
+
}>;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export interface RunnerPassOutcome {
|
|
698
|
+
exitCode: number;
|
|
699
|
+
result?: RunnerPassResult;
|
|
700
|
+
}
|
|
701
|
+
|
|
682
702
|
// ---------------------------------------------------------------------------
|
|
683
703
|
// JWT claim decoder — inlined to avoid pulling a dep just to read an idToken.
|
|
684
704
|
// We do NOT verify the signature here — Cognito already did that when it
|
|
@@ -812,6 +832,11 @@ interface ParsedArgs {
|
|
|
812
832
|
skipPersonal: boolean;
|
|
813
833
|
/** Repeatable company/personal-relative path scope for push-only passes. */
|
|
814
834
|
scopePaths: string[];
|
|
835
|
+
/**
|
|
836
|
+
* Internal watcher-only delete roots. Unlike `scopePaths`, these remain
|
|
837
|
+
* meaningful after the corresponding local file or directory is absent.
|
|
838
|
+
*/
|
|
839
|
+
deleteScopePaths: string[];
|
|
815
840
|
/**
|
|
816
841
|
* Bounded wait (seconds) for the per-root operation lock when another op is
|
|
817
842
|
* already running. `0` → refuse immediately (pre-wait behavior); omitted →
|
|
@@ -832,6 +857,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
832
857
|
let pollRemoteMs: number | undefined;
|
|
833
858
|
let skipPersonal = false;
|
|
834
859
|
const scopePaths: string[] = [];
|
|
860
|
+
const deleteScopePaths: string[] = [];
|
|
835
861
|
let eventPush = false;
|
|
836
862
|
let lockTimeoutSec: number | undefined;
|
|
837
863
|
|
|
@@ -913,6 +939,12 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
913
939
|
scopePaths.push(val);
|
|
914
940
|
break;
|
|
915
941
|
}
|
|
942
|
+
case "--delete-scope-path": {
|
|
943
|
+
const val = argv[++i];
|
|
944
|
+
if (!val) return { error: "--delete-scope-path requires a value" };
|
|
945
|
+
deleteScopePaths.push(val);
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
916
948
|
case "--lock-timeout": {
|
|
917
949
|
const val = argv[++i];
|
|
918
950
|
if (!val) return { error: "--lock-timeout requires a value (seconds)" };
|
|
@@ -964,6 +996,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
|
964
996
|
pollRemoteMs,
|
|
965
997
|
skipPersonal,
|
|
966
998
|
scopePaths,
|
|
999
|
+
deleteScopePaths,
|
|
967
1000
|
eventPush,
|
|
968
1001
|
lockTimeoutSec,
|
|
969
1002
|
};
|
|
@@ -1170,8 +1203,9 @@ export async function runRunner(
|
|
|
1170
1203
|
(() => getValidAccessToken(DEFAULT_COGNITO, { interactive: false }));
|
|
1171
1204
|
const clearSession =
|
|
1172
1205
|
deps.clearSession ?? invalidateCachedTokensByFingerprint;
|
|
1206
|
+
let initialAuthToken: string;
|
|
1173
1207
|
try {
|
|
1174
|
-
await getAccessToken();
|
|
1208
|
+
initialAuthToken = await getAccessToken();
|
|
1175
1209
|
} catch (err) {
|
|
1176
1210
|
if (
|
|
1177
1211
|
(err instanceof CognitoRefreshError && !err.requiresReauth) ||
|
|
@@ -1218,7 +1252,19 @@ export async function runRunner(
|
|
|
1218
1252
|
// Resolved here (not inside `parsed.companies`) so single-company runs also
|
|
1219
1253
|
// get author attribution. `null` is fine — share() simply omits the metadata.
|
|
1220
1254
|
const getClaims = deps.getIdTokenClaims ?? defaultGetIdTokenClaims;
|
|
1221
|
-
|
|
1255
|
+
// A machine token is minted immediately above and is an ID token. Make its
|
|
1256
|
+
// entity binding authoritative for this run: a stale human token cache can
|
|
1257
|
+
// otherwise make an authenticated agent look like a person during fanout
|
|
1258
|
+
// planning, which drops the agent's own personal-vault target. Human callers
|
|
1259
|
+
// still retain the cache as their primary claims source because their bearer
|
|
1260
|
+
// is normally an access token without the full ID-token profile.
|
|
1261
|
+
const authenticatedClaims = decodeJwtClaims(initialAuthToken);
|
|
1262
|
+
const authenticatedMachineClaims =
|
|
1263
|
+
authenticatedClaims?.["custom:entityType"] === "agent" &&
|
|
1264
|
+
typeof authenticatedClaims["custom:entityUid"] === "string"
|
|
1265
|
+
? authenticatedClaims
|
|
1266
|
+
: null;
|
|
1267
|
+
const claims = authenticatedMachineClaims ?? getClaims() ?? authenticatedClaims;
|
|
1222
1268
|
const uploadAuthor: UploadAuthor | undefined =
|
|
1223
1269
|
claims?.sub && claims?.email
|
|
1224
1270
|
? { userSub: claims.sub, email: claims.email }
|
|
@@ -1418,6 +1464,7 @@ export async function runRunner(
|
|
|
1418
1464
|
resolveDeletePolicy,
|
|
1419
1465
|
emit,
|
|
1420
1466
|
scopePaths: parsed.scopePaths,
|
|
1467
|
+
deleteScopePaths: parsed.deleteScopePaths,
|
|
1421
1468
|
telemetryClient: client,
|
|
1422
1469
|
telemetryClaims: claims,
|
|
1423
1470
|
telemetrySessionId,
|
|
@@ -1431,6 +1478,7 @@ export async function runRunner(
|
|
|
1431
1478
|
throw err;
|
|
1432
1479
|
}
|
|
1433
1480
|
const { errors, allConflicts, meaningfulOutcomeCount } = fanout;
|
|
1481
|
+
deps.onPassResult?.({ pushPathResults: fanout.pushPathResults });
|
|
1434
1482
|
const rollup = rollupAllComplete(plan, fanout.stateByCompany);
|
|
1435
1483
|
|
|
1436
1484
|
// The personal-vault target runs last and can pull an older copy of the
|
|
@@ -1860,7 +1908,7 @@ export interface RunnerLoopDeps {
|
|
|
1860
1908
|
* targeted push share one seam and one in-flight guard. The default ignores
|
|
1861
1909
|
* `deps` and forwards just the argv to `runRunner`.
|
|
1862
1910
|
*/
|
|
1863
|
-
runPass?: (passArgv: string[]) => Promise<number>;
|
|
1911
|
+
runPass?: (passArgv: string[]) => Promise<number | RunnerPassOutcome>;
|
|
1864
1912
|
/** Stage-0 backup retention backstop: a dry-run only, never a sync-tick action. */
|
|
1865
1913
|
runBackupPrune?: (hqRoot: string) => Promise<void>;
|
|
1866
1914
|
/**
|
|
@@ -1878,6 +1926,10 @@ export interface RunnerLoopDeps {
|
|
|
1878
1926
|
hqRoot: string;
|
|
1879
1927
|
debounceMs: number;
|
|
1880
1928
|
clock: Clock;
|
|
1929
|
+
captureLocalDeleteSnapshots: (
|
|
1930
|
+
relativePath: string,
|
|
1931
|
+
kind: "unlink" | "unlinkDir",
|
|
1932
|
+
) => LocalDeleteSnapshot[];
|
|
1881
1933
|
}) => WatcherSurface;
|
|
1882
1934
|
/**
|
|
1883
1935
|
* Register a one-shot shutdown signal handler. Defaults to listening for
|
|
@@ -1955,11 +2007,17 @@ export async function runRunnerWithLoop(
|
|
|
1955
2007
|
): Promise<number> {
|
|
1956
2008
|
const parsed = parseArgs(argv);
|
|
1957
2009
|
const runtime = {
|
|
1958
|
-
runPassWithOperationLockAlreadyHeld: (passArgv: string[]) =>
|
|
1959
|
-
|
|
2010
|
+
runPassWithOperationLockAlreadyHeld: async (passArgv: string[]) => {
|
|
2011
|
+
let result: RunnerPassResult | undefined;
|
|
2012
|
+
const exitCode = await runRunner(passArgv, {
|
|
1960
2013
|
operationLockAlreadyHeld: true,
|
|
1961
2014
|
authRequiredExitCode: AUTH_REQUIRED_PASS_EXIT,
|
|
1962
|
-
|
|
2015
|
+
onPassResult: (passResult) => {
|
|
2016
|
+
result = passResult;
|
|
2017
|
+
},
|
|
2018
|
+
});
|
|
2019
|
+
return { exitCode, ...(result ? { result } : {}) };
|
|
2020
|
+
},
|
|
1963
2021
|
authRequiredPassExit: AUTH_REQUIRED_PASS_EXIT,
|
|
1964
2022
|
defaultGetIdTokenClaims,
|
|
1965
2023
|
defaultGetAccessToken: () =>
|
package/src/cli/rescue-core.ts
CHANGED
|
@@ -1515,6 +1515,31 @@ function masterSyncSymlinkTarget(localPath: string): string | null {
|
|
|
1515
1515
|
return tgt.includes("/personal/") || tgt.startsWith("personal/") ? tgt : null;
|
|
1516
1516
|
}
|
|
1517
1517
|
|
|
1518
|
+
/**
|
|
1519
|
+
* Drop a reindex symlink with lstat + unlink semantics: treat the path as a
|
|
1520
|
+
* link and remove the link itself, never following or recursing into its
|
|
1521
|
+
* target.
|
|
1522
|
+
*
|
|
1523
|
+
* `fs.rmSync(p, { force: true })` stat-follows on some platforms (notably
|
|
1524
|
+
* macOS): a symlink whose target is a directory is seen as a directory, and
|
|
1525
|
+
* with `recursive` unset it throws `EISDIR` ("Path is a directory"). During a
|
|
1526
|
+
* rescue apply that abort rolls back the whole update — the exact failure this
|
|
1527
|
+
* guards against (a directory-target overlay symlink such as
|
|
1528
|
+
* core/knowledge/<name> -> personal/knowledge/<name>). `unlink(2)` removes the
|
|
1529
|
+
* link regardless of what it points at, so file- and directory-target reindex
|
|
1530
|
+
* symlinks are dropped identically. reindex.sh re-lays the link afterward.
|
|
1531
|
+
*
|
|
1532
|
+
* A missing path is a no-op (force-equivalent); a non-symlink is left untouched
|
|
1533
|
+
* so we can never recurse into or delete a real directory (the classifier only
|
|
1534
|
+
* ever routes real symlinks here — this stays defensive).
|
|
1535
|
+
*/
|
|
1536
|
+
function dropReindexSymlink(localPath: string): void {
|
|
1537
|
+
const st = lstatOrNull(localPath);
|
|
1538
|
+
if (!st) return; // already gone — nothing to drop
|
|
1539
|
+
if (!st.isSymbolicLink()) return; // never follow/recurse into a real target
|
|
1540
|
+
fs.unlinkSync(localPath);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1518
1543
|
function isUnderPreserve(cfg: Config, rel: string): boolean {
|
|
1519
1544
|
for (const sp of cfg.preserveSubpaths) {
|
|
1520
1545
|
if (rel === sp || rel.startsWith(sp + "/")) return true;
|
|
@@ -1972,7 +1997,7 @@ function buildRescueActions(ctx: WalkCtx): RescueAction[] {
|
|
|
1972
1997
|
break;
|
|
1973
1998
|
case "drop-reindex-symlink":
|
|
1974
1999
|
add(`drop reindex symlink: ${decision.rel}`, [decision.rel], () => {
|
|
1975
|
-
|
|
2000
|
+
dropReindexSymlink(localPath);
|
|
1976
2001
|
ctx.appendLog(`symlink-dropped\t${decision.rel}\t(reindex regenerable)\n`);
|
|
1977
2002
|
});
|
|
1978
2003
|
break;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression for the rescue "drop reindex symlink" fix in
|
|
3
|
+
* src/cli/rescue-core.ts (dropReindexSymlink).
|
|
4
|
+
*
|
|
5
|
+
* The bug: the apply step removed a reindex overlay symlink with
|
|
6
|
+
* `fs.rmSync(localPath, { force: true })`. `rmSync` stat-FOLLOWS on some
|
|
7
|
+
* platforms (notably macOS), so a symlink whose target is a directory — e.g.
|
|
8
|
+
* core/knowledge/ad-creative-engine -> personal/knowledge/ad-creative-engine —
|
|
9
|
+
* is seen as a directory and, with `recursive` unset, throws `EISDIR`
|
|
10
|
+
* ("Path is a directory"). During a rescue apply that abort rolls back the
|
|
11
|
+
* whole update, so /update-hq fails on any install carrying a directory-target
|
|
12
|
+
* overlay symlink. The fix removes the link with lstat + unlink semantics
|
|
13
|
+
* (`fs.unlinkSync`), which drops the link itself regardless of what it points
|
|
14
|
+
* at and never recurses into the target.
|
|
15
|
+
*
|
|
16
|
+
* The macOS `rmSync` follow-behavior is not reproducible with a plain path on
|
|
17
|
+
* Linux CI, so we reproduce it deterministically: `fs` is mocked so `rmSync`
|
|
18
|
+
* throws `EISDIR` on exactly a directory-target symlink (every other call is
|
|
19
|
+
* delegated to the real implementation). Under that fault the OLD code aborts
|
|
20
|
+
* the apply and rolls the whole update back; the fixed code drops the link with
|
|
21
|
+
* `unlinkSync` and the rescue still succeeds.
|
|
22
|
+
*
|
|
23
|
+
* Mirrors rescue-mtime-preserve.test.ts: shim `git clone` to a local fixture
|
|
24
|
+
* and run the real rescue (non-dry-run, --no-backup) so the overlay lays files
|
|
25
|
+
* down and the symlink-drop action really runs.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
|
28
|
+
import { execFileSync } from "child_process";
|
|
29
|
+
import * as fs from "fs";
|
|
30
|
+
import * as os from "os";
|
|
31
|
+
import * as path from "path";
|
|
32
|
+
import { runRescue } from "./rescue-core.js";
|
|
33
|
+
|
|
34
|
+
// Simulate the reporter's platform (macOS): fs.rmSync stat-follows a
|
|
35
|
+
// symlink-to-directory and throws EISDIR ("Path is a directory"). Only the
|
|
36
|
+
// dir-target symlink trips this; every other rmSync call — and every other fs
|
|
37
|
+
// function — is the real implementation, so clone/overlay/backup all work.
|
|
38
|
+
vi.mock("fs", async (importOriginal) => {
|
|
39
|
+
const real = await importOriginal<typeof import("fs")>();
|
|
40
|
+
const patchedRmSync: typeof real.rmSync = ((p: fs.PathLike, opts?: fs.RmOptions) => {
|
|
41
|
+
try {
|
|
42
|
+
const lst = real.lstatSync(p as fs.PathLike);
|
|
43
|
+
if (lst.isSymbolicLink() && real.statSync(p as fs.PathLike).isDirectory()) {
|
|
44
|
+
const err = new Error(
|
|
45
|
+
`EISDIR: illegal operation on a directory, Path is a directory: ${String(p)}`,
|
|
46
|
+
) as NodeJS.ErrnoException;
|
|
47
|
+
err.code = "ERR_FS_EISDIR";
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if ((e as NodeJS.ErrnoException).code === "ERR_FS_EISDIR") throw e;
|
|
52
|
+
// lstat/stat failed (missing path / broken link) — fall through to real rm.
|
|
53
|
+
}
|
|
54
|
+
return real.rmSync(p, opts);
|
|
55
|
+
}) as typeof real.rmSync;
|
|
56
|
+
return { ...real, rmSync: patchedRmSync };
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function has(bin: string, ...args: string[]): boolean {
|
|
60
|
+
try {
|
|
61
|
+
execFileSync(bin, args, { stdio: "ignore" });
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const toolsAvailable = has("git", "--version") && has("rsync", "--version");
|
|
68
|
+
|
|
69
|
+
const EPOCH = 1609459200; // 2021-01-01T00:00:00Z
|
|
70
|
+
|
|
71
|
+
/** Run the rescue in-process, capturing its stdout/stderr. */
|
|
72
|
+
function runRescueCapture(argv: string[], env: NodeJS.ProcessEnv) {
|
|
73
|
+
let stdout = "";
|
|
74
|
+
let stderr = "";
|
|
75
|
+
const origOut = process.stdout.write.bind(process.stdout);
|
|
76
|
+
const origErr = process.stderr.write.bind(process.stderr);
|
|
77
|
+
process.stdout.write = ((chunk: unknown) => {
|
|
78
|
+
stdout += String(chunk);
|
|
79
|
+
return true;
|
|
80
|
+
}) as typeof process.stdout.write;
|
|
81
|
+
process.stderr.write = ((chunk: unknown) => {
|
|
82
|
+
stderr += String(chunk);
|
|
83
|
+
return true;
|
|
84
|
+
}) as typeof process.stderr.write;
|
|
85
|
+
let status: number;
|
|
86
|
+
try {
|
|
87
|
+
status = runRescue(argv, { env }).status;
|
|
88
|
+
} finally {
|
|
89
|
+
process.stdout.write = origOut;
|
|
90
|
+
process.stderr.write = origErr;
|
|
91
|
+
}
|
|
92
|
+
return { status, stdout, stderr };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe.skipIf(!toolsAvailable)("rescue drops directory-target reindex symlinks", () => {
|
|
96
|
+
let workDir: string;
|
|
97
|
+
let upstream: string;
|
|
98
|
+
let shimDir: string;
|
|
99
|
+
let floorSha: string;
|
|
100
|
+
let env: NodeJS.ProcessEnv;
|
|
101
|
+
|
|
102
|
+
const gitAt = (cwd: string, ...args: string[]) =>
|
|
103
|
+
execFileSync("git", args, {
|
|
104
|
+
cwd,
|
|
105
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
106
|
+
env: {
|
|
107
|
+
...process.env,
|
|
108
|
+
GIT_AUTHOR_NAME: "t",
|
|
109
|
+
GIT_AUTHOR_EMAIL: "t@t",
|
|
110
|
+
GIT_COMMITTER_NAME: "t",
|
|
111
|
+
GIT_COMMITTER_EMAIL: "t@t",
|
|
112
|
+
GIT_AUTHOR_DATE: `${EPOCH} +0000`,
|
|
113
|
+
GIT_COMMITTER_DATE: `${EPOCH} +0000`,
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
.toString()
|
|
117
|
+
.trim();
|
|
118
|
+
|
|
119
|
+
beforeAll(() => {
|
|
120
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-dirsymlink-"));
|
|
121
|
+
|
|
122
|
+
// --- "upstream" repo: a single core/ file so the wipe set includes core/ --
|
|
123
|
+
upstream = path.join(workDir, "upstream");
|
|
124
|
+
fs.mkdirSync(path.join(upstream, "core/knowledge"), { recursive: true });
|
|
125
|
+
gitAt(workDir, "init", "-b", "main", "upstream");
|
|
126
|
+
fs.writeFileSync(path.join(upstream, "core/knowledge/README.md"), "shipped\n");
|
|
127
|
+
gitAt(upstream, "add", "-A");
|
|
128
|
+
gitAt(upstream, "commit", "-m", "floor");
|
|
129
|
+
floorSha = gitAt(upstream, "rev-parse", "HEAD");
|
|
130
|
+
|
|
131
|
+
// --- git shim: redirect `git clone <github-url>` to the local fixture -----
|
|
132
|
+
const realGit =
|
|
133
|
+
execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
|
|
134
|
+
shimDir = path.join(workDir, "shim");
|
|
135
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
136
|
+
const shim = `#!/usr/bin/env bash
|
|
137
|
+
if [ "$1" = "clone" ]; then
|
|
138
|
+
args=()
|
|
139
|
+
for a in "$@"; do
|
|
140
|
+
case "$a" in
|
|
141
|
+
https://github.com/*) a=${JSON.stringify(upstream)} ;;
|
|
142
|
+
esac
|
|
143
|
+
args+=("$a")
|
|
144
|
+
done
|
|
145
|
+
exec ${JSON.stringify(realGit)} "\${args[@]}"
|
|
146
|
+
fi
|
|
147
|
+
exec ${JSON.stringify(realGit)} "$@"
|
|
148
|
+
`;
|
|
149
|
+
fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
|
|
150
|
+
env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterAll(() => {
|
|
154
|
+
if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build a fresh local HQ root carrying a directory-target reindex symlink
|
|
159
|
+
* (core/knowledge/ad-creative-engine -> <hqRoot>/personal/knowledge/ad-creative-engine),
|
|
160
|
+
* whose target directory holds a sentinel file that must survive the drop.
|
|
161
|
+
*/
|
|
162
|
+
function makeHqRoot(): { hqRoot: string; link: string; targetDir: string; sentinel: string } {
|
|
163
|
+
const hqRoot = fs.mkdtempSync(path.join(workDir, "hq-"));
|
|
164
|
+
// HQ-root shape check: companies/ + one of .claude/|core/|personal/.
|
|
165
|
+
fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
|
|
166
|
+
fs.mkdirSync(path.join(hqRoot, "core/knowledge"), { recursive: true });
|
|
167
|
+
// Unchanged vs upstream so the overlay is a no-op for it.
|
|
168
|
+
fs.writeFileSync(path.join(hqRoot, "core/knowledge/README.md"), "shipped\n");
|
|
169
|
+
|
|
170
|
+
const targetDir = path.join(hqRoot, "personal/knowledge/ad-creative-engine");
|
|
171
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
172
|
+
const sentinel = path.join(targetDir, "brief.md");
|
|
173
|
+
fs.writeFileSync(sentinel, "authored locally\n");
|
|
174
|
+
|
|
175
|
+
const link = path.join(hqRoot, "core/knowledge/ad-creative-engine");
|
|
176
|
+
// Absolute target that includes "/personal/" — matches the overlay
|
|
177
|
+
// convention masterSyncSymlinkTarget() recognizes.
|
|
178
|
+
fs.symlinkSync(targetDir, link);
|
|
179
|
+
|
|
180
|
+
return { hqRoot, link, targetDir, sentinel };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function rescueArgs(hqRoot: string): string[] {
|
|
184
|
+
return [
|
|
185
|
+
"--hq-root", hqRoot,
|
|
186
|
+
"--source", "test/repo",
|
|
187
|
+
"--ref", "main",
|
|
188
|
+
"--floor-sha", floorSha,
|
|
189
|
+
"--yes",
|
|
190
|
+
"--no-backup",
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
it("sanity: the mocked rmSync reproduces the macOS EISDIR on a dir-target symlink", () => {
|
|
195
|
+
const { link, targetDir, sentinel } = makeHqRoot();
|
|
196
|
+
// The old apply path used exactly this call — it must throw under the fault.
|
|
197
|
+
expect(() => fs.rmSync(link, { force: true })).toThrow(/Path is a directory/);
|
|
198
|
+
// ...and the fixed path (unlink) removes the link while sparing the target.
|
|
199
|
+
fs.unlinkSync(link);
|
|
200
|
+
expect(fs.existsSync(link)).toBe(false);
|
|
201
|
+
expect(fs.existsSync(targetDir)).toBe(true);
|
|
202
|
+
expect(fs.readFileSync(sentinel, "utf-8")).toBe("authored locally\n");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("does not abort the apply on a directory-target symlink (drops it, spares the target)", () => {
|
|
206
|
+
const { hqRoot, link, targetDir, sentinel } = makeHqRoot();
|
|
207
|
+
|
|
208
|
+
const r = runRescueCapture(rescueArgs(hqRoot), env);
|
|
209
|
+
|
|
210
|
+
// The apply must survive the simulated-macOS rmSync fault: the fix removes
|
|
211
|
+
// the link with unlink semantics, so the whole update no longer rolls back.
|
|
212
|
+
// (The old rmSync-based drop throws EISDIR here → non-zero + rollback.)
|
|
213
|
+
if (r.status !== 0) {
|
|
214
|
+
throw new Error(`rescue aborted on the dir-target symlink (${r.status}):\n${r.stdout}\n${r.stderr}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// The link itself is gone (reindex.sh re-lays it later)...
|
|
218
|
+
expect(fs.existsSync(link)).toBe(false);
|
|
219
|
+
// ...but the target directory and its contents are untouched — the drop
|
|
220
|
+
// must never follow/recurse into the target.
|
|
221
|
+
expect(fs.existsSync(targetDir)).toBe(true);
|
|
222
|
+
expect(fs.readFileSync(sentinel, "utf-8")).toBe("authored locally\n");
|
|
223
|
+
});
|
|
224
|
+
});
|
package/src/cli/share.test.ts
CHANGED
|
@@ -2008,6 +2008,11 @@ describe("share", () => {
|
|
|
2008
2008
|
});
|
|
2009
2009
|
|
|
2010
2010
|
expect(result.filesDeleted).toBe(1);
|
|
2011
|
+
expect(result.pathResults).toContainEqual({
|
|
2012
|
+
path: "knowledge",
|
|
2013
|
+
status: "accepted",
|
|
2014
|
+
operation: "delete",
|
|
2015
|
+
});
|
|
2011
2016
|
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
|
|
2012
2017
|
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
2013
2018
|
expect(journal.files["knowledge"]).toBeUndefined();
|
|
@@ -2198,6 +2203,44 @@ describe("share", () => {
|
|
|
2198
2203
|
);
|
|
2199
2204
|
});
|
|
2200
2205
|
|
|
2206
|
+
it("personalMode excludes generated core mirror symlinks but keeps canonical core files", async () => {
|
|
2207
|
+
const packagePolicy = path.join(
|
|
2208
|
+
tmpDir,
|
|
2209
|
+
"core",
|
|
2210
|
+
"packages",
|
|
2211
|
+
"hq-pack-example",
|
|
2212
|
+
"policies",
|
|
2213
|
+
"generated.md",
|
|
2214
|
+
);
|
|
2215
|
+
const generatedMirror = path.join(tmpDir, "core", "policies", "generated.md");
|
|
2216
|
+
const canonicalCoreFile = path.join(tmpDir, "core", "policies", "shipped.md");
|
|
2217
|
+
fs.mkdirSync(path.dirname(packagePolicy), { recursive: true });
|
|
2218
|
+
fs.mkdirSync(path.dirname(generatedMirror), { recursive: true });
|
|
2219
|
+
fs.writeFileSync(packagePolicy, "generated package policy\n");
|
|
2220
|
+
fs.symlinkSync(packagePolicy, generatedMirror);
|
|
2221
|
+
fs.writeFileSync(canonicalCoreFile, "shipped core policy\n");
|
|
2222
|
+
|
|
2223
|
+
const result = await share({
|
|
2224
|
+
paths: [path.join(tmpDir, "core")],
|
|
2225
|
+
entityContext: makeEntityContext({
|
|
2226
|
+
uid: "prs_01PERSONALTEST",
|
|
2227
|
+
slug: "__hq_personal_vault__",
|
|
2228
|
+
bucketName: "hq-vault-prs-01PERSONALTEST",
|
|
2229
|
+
}),
|
|
2230
|
+
hqRoot: tmpDir,
|
|
2231
|
+
personalMode: true,
|
|
2232
|
+
journalSlug: "personal-generated-core-mirror",
|
|
2233
|
+
});
|
|
2234
|
+
|
|
2235
|
+
expect(result.filesExcludedByPolicy).toBe(1);
|
|
2236
|
+
expect(result.filesUploaded).toBe(2);
|
|
2237
|
+
expect(vi.mocked(uploadSymlink)).not.toHaveBeenCalled();
|
|
2238
|
+
expect(vi.mocked(uploadFile).mock.calls.map((call) => call[2]).sort()).toEqual([
|
|
2239
|
+
"core/packages/hq-pack-example/policies/generated.md",
|
|
2240
|
+
"core/policies/shipped.md",
|
|
2241
|
+
]);
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2201
2244
|
it(
|
|
2202
2245
|
"personal-vault override does NOT apply to company vaults: `owned-only` on cmp_ entity still " +
|
|
2203
2246
|
"refuses to tombstone a direction:'down' entry (multi-user curation intact)",
|
|
@@ -3033,7 +3076,7 @@ describe("share", () => {
|
|
|
3033
3076
|
});
|
|
3034
3077
|
});
|
|
3035
3078
|
|
|
3036
|
-
it("
|
|
3079
|
+
it("daemon-stopped deletion has no watcher intent and is safely refused", async () => {
|
|
3037
3080
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
3038
3081
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
3039
3082
|
// Journal entry from before remoteEtag tracking — no etag to compare
|
|
@@ -3078,6 +3121,12 @@ describe("share", () => {
|
|
|
3078
3121
|
reason: "missing-delete-intent",
|
|
3079
3122
|
});
|
|
3080
3123
|
expect(result.filesRefusedStale).toBe(1);
|
|
3124
|
+
expect(result.pathResults).toContainEqual({
|
|
3125
|
+
path: "legacy-no-etag.md",
|
|
3126
|
+
status: "refused",
|
|
3127
|
+
operation: "delete",
|
|
3128
|
+
reason: "missing-delete-intent",
|
|
3129
|
+
});
|
|
3081
3130
|
});
|
|
3082
3131
|
|
|
3083
3132
|
it("currency-gated: real-world /update-hq scenario — direction:'down' delete propagates when current", async () => {
|
|
@@ -3351,6 +3400,58 @@ describe("share", () => {
|
|
|
3351
3400
|
// passing in the allowed top-level directories — share() trusts its
|
|
3352
3401
|
// `paths` input.
|
|
3353
3402
|
|
|
3403
|
+
it("plans an intent-backed personal delete from an explicit missing scope root", async () => {
|
|
3404
|
+
const key = "deleted-top-level.md";
|
|
3405
|
+
const journalSlug = "personal-scoped-delete";
|
|
3406
|
+
const journalPath = path.join(
|
|
3407
|
+
stateDir,
|
|
3408
|
+
`sync-journal.${journalSlug}.json`,
|
|
3409
|
+
);
|
|
3410
|
+
fs.writeFileSync(
|
|
3411
|
+
journalPath,
|
|
3412
|
+
JSON.stringify({
|
|
3413
|
+
version: "2",
|
|
3414
|
+
lastSync: new Date().toISOString(),
|
|
3415
|
+
pulls: [],
|
|
3416
|
+
files: {
|
|
3417
|
+
[key]: {
|
|
3418
|
+
hash: "captured-hash",
|
|
3419
|
+
size: 8,
|
|
3420
|
+
syncedAt: new Date().toISOString(),
|
|
3421
|
+
direction: "up",
|
|
3422
|
+
remoteEtag: "captured-etag",
|
|
3423
|
+
kind: "file",
|
|
3424
|
+
localDeleteIntent: deleteIntent(
|
|
3425
|
+
"captured-etag",
|
|
3426
|
+
"captured-hash",
|
|
3427
|
+
),
|
|
3428
|
+
},
|
|
3429
|
+
},
|
|
3430
|
+
}),
|
|
3431
|
+
);
|
|
3432
|
+
|
|
3433
|
+
const result = await share({
|
|
3434
|
+
// computePersonalVaultPaths() has no path to contribute after this
|
|
3435
|
+
// top-level file is gone. The watcher-captured root is the sole scope.
|
|
3436
|
+
paths: [],
|
|
3437
|
+
deleteScopeRoots: [key],
|
|
3438
|
+
company: "acme",
|
|
3439
|
+
vaultConfig: mockConfig,
|
|
3440
|
+
hqRoot: tmpDir,
|
|
3441
|
+
personalMode: true,
|
|
3442
|
+
journalSlug,
|
|
3443
|
+
skipUnchanged: true,
|
|
3444
|
+
propagateDeletes: true,
|
|
3445
|
+
propagateDeletePolicy: "owned-only",
|
|
3446
|
+
});
|
|
3447
|
+
|
|
3448
|
+
expect(result.filesDeleted).toBe(1);
|
|
3449
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), key);
|
|
3450
|
+
expect(
|
|
3451
|
+
JSON.parse(fs.readFileSync(journalPath, "utf8")).files[key],
|
|
3452
|
+
).toBeUndefined();
|
|
3453
|
+
});
|
|
3454
|
+
|
|
3354
3455
|
// ── Decommission prefixes ──────────────────────────────────────────────────
|
|
3355
3456
|
//
|
|
3356
3457
|
// `decommissionPrefixes` is the "company was promoted to its own team
|
|
@@ -4059,10 +4160,12 @@ describe("share", () => {
|
|
|
4059
4160
|
total,
|
|
4060
4161
|
missing,
|
|
4061
4162
|
direction = "up" as "up" | "down",
|
|
4163
|
+
withIntents = true,
|
|
4062
4164
|
}: {
|
|
4063
4165
|
total: number;
|
|
4064
4166
|
missing: number;
|
|
4065
4167
|
direction?: "up" | "down";
|
|
4168
|
+
withIntents?: boolean;
|
|
4066
4169
|
}): { companyRoot: string; journalPath: string } {
|
|
4067
4170
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4068
4171
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -4075,7 +4178,7 @@ describe("share", () => {
|
|
|
4075
4178
|
direction: "up" | "down";
|
|
4076
4179
|
remoteEtag: string;
|
|
4077
4180
|
kind: "file";
|
|
4078
|
-
localDeleteIntent
|
|
4181
|
+
localDeleteIntent?: ReturnType<typeof deleteIntent>;
|
|
4079
4182
|
}
|
|
4080
4183
|
> = {};
|
|
4081
4184
|
for (let i = 0; i < total; i++) {
|
|
@@ -4087,7 +4190,9 @@ describe("share", () => {
|
|
|
4087
4190
|
direction,
|
|
4088
4191
|
remoteEtag: `etag-${i}`,
|
|
4089
4192
|
kind: "file",
|
|
4090
|
-
|
|
4193
|
+
...(withIntents
|
|
4194
|
+
? { localDeleteIntent: deleteIntent(`etag-${i}`, "h") }
|
|
4195
|
+
: {}),
|
|
4091
4196
|
};
|
|
4092
4197
|
if (i >= missing) {
|
|
4093
4198
|
fs.writeFileSync(path.join(companyRoot, key), "hi");
|
|
@@ -4110,7 +4215,11 @@ describe("share", () => {
|
|
|
4110
4215
|
});
|
|
4111
4216
|
|
|
4112
4217
|
it("trips at the 11/100 boundary (11% > 10%, abs >= 10): refuses every delete and emits bulk-asymmetry event", async () => {
|
|
4113
|
-
const { companyRoot, journalPath } = setupJournal({
|
|
4218
|
+
const { companyRoot, journalPath } = setupJournal({
|
|
4219
|
+
total: 100,
|
|
4220
|
+
missing: 11,
|
|
4221
|
+
withIntents: false,
|
|
4222
|
+
});
|
|
4114
4223
|
// currency-gated: remote HEAD would match every journal etag — guard
|
|
4115
4224
|
// must trip BEFORE any HEAD round-trip occurs.
|
|
4116
4225
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
@@ -4166,6 +4275,33 @@ describe("share", () => {
|
|
|
4166
4275
|
expect(perKeyRefusals.length).toBe(11);
|
|
4167
4276
|
});
|
|
4168
4277
|
|
|
4278
|
+
it("intent-backed 11/100 subtree disappearance bypasses the breaker and deletes every covered path", async () => {
|
|
4279
|
+
const { companyRoot } = setupJournal({ total: 100, missing: 11 });
|
|
4280
|
+
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
4281
|
+
etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
|
|
4282
|
+
lastModified: new Date(),
|
|
4283
|
+
size: 5,
|
|
4284
|
+
}));
|
|
4285
|
+
const events: Array<{ type: string }> = [];
|
|
4286
|
+
|
|
4287
|
+
const result = await share({
|
|
4288
|
+
paths: [companyRoot],
|
|
4289
|
+
company: "acme",
|
|
4290
|
+
vaultConfig: mockConfig,
|
|
4291
|
+
hqRoot: tmpDir,
|
|
4292
|
+
skipUnchanged: true,
|
|
4293
|
+
propagateDeletes: true,
|
|
4294
|
+
propagateDeletePolicy: "currency-gated",
|
|
4295
|
+
onEvent: (e) => events.push(e as { type: string }),
|
|
4296
|
+
});
|
|
4297
|
+
|
|
4298
|
+
expect(result.filesDeleted).toBe(11);
|
|
4299
|
+
expect(deleteRemoteFile).toHaveBeenCalledTimes(11);
|
|
4300
|
+
expect(
|
|
4301
|
+
events.find((e) => e.type === "delete-refused-bulk-asymmetry"),
|
|
4302
|
+
).toBeUndefined();
|
|
4303
|
+
});
|
|
4304
|
+
|
|
4169
4305
|
it("does NOT trip at 9/100 (below ratio): all candidates delete normally", async () => {
|
|
4170
4306
|
const { companyRoot } = setupJournal({ total: 100, missing: 9 });
|
|
4171
4307
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
@@ -4232,7 +4368,11 @@ describe("share", () => {
|
|
|
4232
4368
|
});
|
|
4233
4369
|
|
|
4234
4370
|
it("fully-empty mirror (100/100) trips the guard — the canonical mass-delete failure mode", async () => {
|
|
4235
|
-
const { companyRoot, journalPath } = setupJournal({
|
|
4371
|
+
const { companyRoot, journalPath } = setupJournal({
|
|
4372
|
+
total: 50,
|
|
4373
|
+
missing: 50,
|
|
4374
|
+
withIntents: false,
|
|
4375
|
+
});
|
|
4236
4376
|
vi.mocked(headRemoteFile).mockImplementation(async (_ctx, key) => ({
|
|
4237
4377
|
etag: `etag-${parseInt(key.replace(/[^0-9]/g, ""), 10)}`,
|
|
4238
4378
|
lastModified: new Date(),
|
|
@@ -4288,7 +4428,11 @@ describe("share", () => {
|
|
|
4288
4428
|
});
|
|
4289
4429
|
|
|
4290
4430
|
it("owned-only policy also goes through the guard: 11/100 missing direction:'up' entries refused", async () => {
|
|
4291
|
-
const { companyRoot, journalPath } = setupJournal({
|
|
4431
|
+
const { companyRoot, journalPath } = setupJournal({
|
|
4432
|
+
total: 100,
|
|
4433
|
+
missing: 11,
|
|
4434
|
+
withIntents: false,
|
|
4435
|
+
});
|
|
4292
4436
|
|
|
4293
4437
|
const events: Array<{ type: string }> = [];
|
|
4294
4438
|
const result = await share({
|