@indigoai-us/hq-cloud 6.15.0 → 6.15.2
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-mutation.d.ts +16 -0
- package/dist/bin/sync-mutation.d.ts.map +1 -0
- package/dist/bin/sync-mutation.js +60 -0
- package/dist/bin/sync-mutation.js.map +1 -0
- package/dist/bin/sync-mutation.test.d.ts +2 -0
- package/dist/bin/sync-mutation.test.d.ts.map +1 -0
- package/dist/bin/sync-mutation.test.js +165 -0
- package/dist/bin/sync-mutation.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.d.ts +2 -0
- package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.test.js +36 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +98 -8
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +109 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict-recovery.test.d.ts +2 -0
- package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
- package/dist/cli/conflict-recovery.test.js +201 -0
- package/dist/cli/conflict-recovery.test.js.map +1 -0
- package/dist/cli/conflict.d.ts +60 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +333 -0
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/sync.d.ts +35 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +85 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +79 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/candidate-uploader.d.ts +88 -0
- package/dist/sync/candidate-uploader.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.js +212 -0
- package/dist/sync/candidate-uploader.js.map +1 -0
- package/dist/sync/candidate-uploader.test.d.ts +2 -0
- package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.test.js +132 -0
- package/dist/sync/candidate-uploader.test.js.map +1 -0
- package/dist/sync/delta-client.d.ts +73 -0
- package/dist/sync/delta-client.d.ts.map +1 -0
- package/dist/sync/delta-client.js +201 -0
- package/dist/sync/delta-client.js.map +1 -0
- package/dist/sync/delta-client.test.d.ts +2 -0
- package/dist/sync/delta-client.test.d.ts.map +1 -0
- package/dist/sync/delta-client.test.js +97 -0
- package/dist/sync/delta-client.test.js.map +1 -0
- package/dist/sync/durable-apply.d.ts +76 -0
- package/dist/sync/durable-apply.d.ts.map +1 -0
- package/dist/sync/durable-apply.js +530 -0
- package/dist/sync/durable-apply.js.map +1 -0
- package/dist/sync/durable-apply.test.d.ts +2 -0
- package/dist/sync/durable-apply.test.d.ts.map +1 -0
- package/dist/sync/durable-apply.test.js +180 -0
- package/dist/sync/durable-apply.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +33 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +149 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +142 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/multipart-uploader.d.ts +99 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.js +447 -0
- package/dist/sync/multipart-uploader.js.map +1 -0
- package/dist/sync/multipart-uploader.test.d.ts +2 -0
- package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.test.js +119 -0
- package/dist/sync/multipart-uploader.test.js.map +1 -0
- package/dist/sync/mutation-client.d.ts +85 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +245 -0
- package/dist/sync/mutation-client.js.map +1 -0
- package/dist/sync/mutation-client.test.d.ts +2 -0
- package/dist/sync/mutation-client.test.d.ts.map +1 -0
- package/dist/sync/mutation-client.test.js +51 -0
- package/dist/sync/mutation-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +45 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +101 -0
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +54 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts +69 -0
- package/dist/sync/scope-inventory-client.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.js +210 -0
- package/dist/sync/scope-inventory-client.js.map +1 -0
- package/dist/sync/scope-inventory-client.test.d.ts +2 -0
- package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.test.js +94 -0
- package/dist/sync/scope-inventory-client.test.js.map +1 -0
- package/dist/sync/snapshot-client.d.ts +98 -0
- package/dist/sync/snapshot-client.d.ts.map +1 -0
- package/dist/sync/snapshot-client.js +402 -0
- package/dist/sync/snapshot-client.js.map +1 -0
- package/dist/sync/snapshot-client.test.d.ts +2 -0
- package/dist/sync/snapshot-client.test.d.ts.map +1 -0
- package/dist/sync/snapshot-client.test.js +169 -0
- package/dist/sync/snapshot-client.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +97 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.js +273 -0
- package/dist/sync/uploader-finalization.js.map +1 -0
- package/dist/sync/uploader-finalization.test.d.ts +2 -0
- package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.test.js +92 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -0
- package/dist/telemetry.d.ts +11 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +21 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +80 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +6 -1
- package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
- package/.github/workflows/ci.yml +0 -84
- package/.github/workflows/publish.yml +0 -56
- package/.github/workflows/unreleased-commits-nag.yml +0 -256
- package/eslint.config.js +0 -67
- package/pnpm-workspace.yaml +0 -2
- package/scripts/presign-transport-e2e.mjs +0 -250
- package/scripts/vault-rebaseline.sh +0 -323
- package/scripts/vault-rescue.sh +0 -332
- package/src/active-company.test.ts +0 -188
- package/src/active-company.ts +0 -168
- package/src/agent-codex-instructions.test.ts +0 -332
- package/src/agent-codex-instructions.ts +0 -309
- package/src/auth.ts +0 -146
- package/src/backup-prune.test.ts +0 -98
- package/src/backup-prune.ts +0 -182
- package/src/bin/backup-prune-runner.ts +0 -33
- package/src/bin/rescue-runner.ts +0 -25
- package/src/bin/sync-runner-company.ts +0 -695
- package/src/bin/sync-runner-events.test.ts +0 -143
- package/src/bin/sync-runner-events.ts +0 -55
- package/src/bin/sync-runner-planning.test.ts +0 -311
- package/src/bin/sync-runner-planning.ts +0 -258
- package/src/bin/sync-runner-rollup.test.ts +0 -37
- package/src/bin/sync-runner-rollup.ts +0 -97
- package/src/bin/sync-runner-telemetry.ts +0 -15
- package/src/bin/sync-runner-watch-loop.ts +0 -1235
- package/src/bin/sync-runner-watch-routes.test.ts +0 -71
- package/src/bin/sync-runner-watch-routes.ts +0 -184
- package/src/bin/sync-runner.test.ts +0 -8767
- package/src/bin/sync-runner.ts +0 -2190
- package/src/cli/accept.ts +0 -124
- package/src/cli/conflict.ts +0 -119
- package/src/cli/doctor.test.ts +0 -581
- package/src/cli/doctor.ts +0 -642
- package/src/cli/index.ts +0 -49
- package/src/cli/invite.test.ts +0 -250
- package/src/cli/invite.ts +0 -214
- package/src/cli/promote.ts +0 -157
- package/src/cli/reindex-knowledge.test.ts +0 -307
- package/src/cli/reindex-knowledge.ts +0 -450
- package/src/cli/reindex.test.ts +0 -957
- package/src/cli/reindex.ts +0 -979
- package/src/cli/rescue-classify-ordering.test.ts +0 -548
- package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
- package/src/cli/rescue-core.ts +0 -3011
- package/src/cli/rescue-drift-reconcile.test.ts +0 -179
- package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
- package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
- package/src/cli/rescue-hq-root-guard.test.ts +0 -232
- package/src/cli/rescue-journal-reconcile.test.ts +0 -215
- package/src/cli/rescue-mtime-preserve.test.ts +0 -203
- package/src/cli/rescue-settings-reconcile.test.ts +0 -637
- package/src/cli/rescue-snapshot.test.ts +0 -57
- package/src/cli/rescue-snapshot.ts +0 -51
- package/src/cli/rescue.reindex.test.ts +0 -63
- package/src/cli/rescue.test.ts +0 -131
- package/src/cli/rescue.ts +0 -182
- package/src/cli/share.test.ts +0 -7843
- package/src/cli/share.ts +0 -3663
- package/src/cli/sync-scope.test.ts +0 -652
- package/src/cli/sync.test.ts +0 -5207
- package/src/cli/sync.ts +0 -3470
- package/src/cli/tombstones.ts +0 -106
- package/src/cli/watch-event-push-conflict.test.ts +0 -234
- package/src/client-info.test.ts +0 -214
- package/src/client-info.ts +0 -121
- package/src/cognito-auth.test.ts +0 -712
- package/src/cognito-auth.ts +0 -1422
- package/src/company-resolver.test.ts +0 -618
- package/src/company-resolver.ts +0 -521
- package/src/context.test.ts +0 -583
- package/src/context.ts +0 -378
- package/src/daemon-worker.ts +0 -26
- package/src/daemon.ts +0 -99
- package/src/entity-resolver.test.ts +0 -315
- package/src/entity-resolver.ts +0 -180
- package/src/ignore.test.ts +0 -466
- package/src/ignore.ts +0 -469
- package/src/index.ts +0 -439
- package/src/journal.test.ts +0 -968
- package/src/journal.ts +0 -765
- package/src/lib/cloud-authoritative.test.ts +0 -45
- package/src/lib/cloud-authoritative.ts +0 -59
- package/src/lib/conflict-file.ts +0 -86
- package/src/lib/conflict-index.ts +0 -289
- package/src/lib/conflict.test.ts +0 -348
- package/src/lib/describe-error.test.ts +0 -100
- package/src/lib/describe-error.ts +0 -58
- package/src/lib/exit-codes.ts +0 -24
- package/src/lib/machine-id.test.ts +0 -231
- package/src/lib/machine-id.ts +0 -175
- package/src/lib/net-errors.test.ts +0 -65
- package/src/lib/net-errors.ts +0 -86
- package/src/lib/readlink-safe.test.ts +0 -43
- package/src/lib/readlink-safe.ts +0 -29
- package/src/local-path-codec.test.ts +0 -138
- package/src/local-path-codec.ts +0 -161
- package/src/machine-auth.test.ts +0 -1323
- package/src/manifest-reconcile.test.ts +0 -1123
- package/src/manifest-reconcile.ts +0 -518
- package/src/object-io.test.ts +0 -1221
- package/src/object-io.ts +0 -1306
- package/src/operation-lock.test.ts +0 -484
- package/src/operation-lock.ts +0 -680
- package/src/outcome-telemetry.test.ts +0 -498
- package/src/outcome-telemetry.ts +0 -639
- package/src/personal-vault-exclusions.test.ts +0 -308
- package/src/personal-vault-exclusions.ts +0 -354
- package/src/personal-vault.test.ts +0 -756
- package/src/personal-vault.ts +0 -496
- package/src/prefix-coalesce.test.ts +0 -240
- package/src/prefix-coalesce.ts +0 -273
- package/src/public-surface.test.ts +0 -117
- package/src/qmd-reindex.test.ts +0 -877
- package/src/qmd-reindex.ts +0 -842
- package/src/read-only-state-dir.test.ts +0 -188
- package/src/remote-pull.test.ts +0 -1130
- package/src/remote-pull.ts +0 -618
- package/src/s3.symlink-materialize.test.ts +0 -492
- package/src/s3.test.ts +0 -1789
- package/src/s3.ts +0 -1532
- package/src/schemas/signal-types.test.ts +0 -82
- package/src/schemas/signal-types.ts +0 -38
- package/src/schemas/source-channels.test.ts +0 -82
- package/src/schemas/source-channels.ts +0 -53
- package/src/scope-shrink.test.ts +0 -633
- package/src/scope-shrink.ts +0 -481
- package/src/signals/get.test.ts +0 -310
- package/src/signals/get.ts +0 -75
- package/src/signals/internals.ts +0 -195
- package/src/signals/list.test.ts +0 -420
- package/src/signals/list.ts +0 -79
- package/src/signals/parse.ts +0 -8
- package/src/signals/types.ts +0 -91
- package/src/skill-telemetry.test.ts +0 -1825
- package/src/skill-telemetry.ts +0 -1439
- package/src/sources/get.test.ts +0 -293
- package/src/sources/get.ts +0 -66
- package/src/sources/internals.ts +0 -198
- package/src/sources/list.test.ts +0 -402
- package/src/sources/list.ts +0 -84
- package/src/sources/parse.ts +0 -43
- package/src/sources/types.ts +0 -84
- package/src/sync/event-sync.test.ts +0 -594
- package/src/sync/event-sync.ts +0 -545
- package/src/sync/feature-flags.test.ts +0 -378
- package/src/sync/feature-flags.ts +0 -62
- package/src/sync/index.ts +0 -76
- package/src/sync/lease-client.test.ts +0 -128
- package/src/sync/lease-client.ts +0 -207
- package/src/sync/logger.test.ts +0 -242
- package/src/sync/logger.ts +0 -79
- package/src/sync/metrics.test.ts +0 -462
- package/src/sync/metrics.ts +0 -213
- package/src/sync/pull-scope.ts +0 -265
- package/src/sync/push-event.test.ts +0 -266
- package/src/sync/push-event.ts +0 -224
- package/src/sync/push-receiver.test.ts +0 -566
- package/src/sync/push-receiver.ts +0 -1048
- package/src/sync/push-transport.ts +0 -231
- package/src/sync/realtime-rollout.test.ts +0 -86
- package/src/sync/realtime-rollout.ts +0 -262
- package/src/sync/state-store.test.ts +0 -194
- package/src/sync/state-store.ts +0 -727
- package/src/sync-core.ts +0 -58
- package/src/sync-progress.test.ts +0 -94
- package/src/sync-progress.ts +0 -140
- package/src/telemetry-events.test.ts +0 -88
- package/src/telemetry-events.ts +0 -205
- package/src/telemetry.test.ts +0 -1280
- package/src/telemetry.ts +0 -1109
- package/src/types.ts +0 -314
- package/src/vault-client.test.ts +0 -1380
- package/src/vault-client.ts +0 -1694
- package/src/version.ts +0 -24
- package/src/watch-roots.test.ts +0 -278
- package/src/watch-roots.ts +0 -162
- package/src/watcher-event-gate.test.ts +0 -212
- package/src/watcher.test.ts +0 -1079
- package/src/watcher.ts +0 -1741
- package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
- package/test/e2e/sync/transient-company-leg.test.ts +0 -384
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
- package/test/e2e/watcher-real-chokidar.test.ts +0 -165
- package/test/e2e/watcher-recursive-backend.test.ts +0 -181
- package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
- package/test/invite-flow.integration.test.ts +0 -244
- package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
- package/test/share-sync.integration.test.ts +0 -213
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -22
package/src/cli/tombstones.ts
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared FILE_TOMBSTONE fetch used by BOTH the pull planner (`sync.ts`) and the
|
|
3
|
-
* push planner (`share.ts`).
|
|
4
|
-
*
|
|
5
|
-
* A FILE_TOMBSTONE is the authoritative record of an intentional, scoped delete
|
|
6
|
-
* (`hq files delete <prefix>` → `POST /v1/files/delete` → `recordDeletions`).
|
|
7
|
-
* Both sync legs consult it so a deleted key is neither re-downloaded (pull) nor
|
|
8
|
-
* re-uploaded (push) and therefore cannot be resurrected by a behind peer.
|
|
9
|
-
*
|
|
10
|
-
* Lives in its own module so `share.ts` can use the fetch without importing from
|
|
11
|
-
* `sync.ts` — `sync.ts` already imports values from `share.ts`
|
|
12
|
-
* (`isEphemeralPath`, `isMalformedVaultKey`), so the reverse value-import would
|
|
13
|
-
* create a runtime ESM cycle.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type { VaultServiceConfig } from "../types.js";
|
|
17
|
-
import { toPosixKey } from "../s3.js";
|
|
18
|
-
|
|
19
|
-
/** Timeout for the best-effort FILE_TOMBSTONE fetch (GET /v1/files/tombstones). */
|
|
20
|
-
export const FETCH_TOMBSTONES_TIMEOUT_MS = 5000;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* A FILE_TOMBSTONE as the planners need it: the deleted key + when it was
|
|
24
|
-
* deleted. The `deletedAt` timestamp is the decisive precedence signal on the
|
|
25
|
-
* pull side — a remote object newer than it is a genuine re-create (sync it), an
|
|
26
|
-
* object at or older than it is a stale resurrection of a deleted key (suppress
|
|
27
|
-
* it).
|
|
28
|
-
*/
|
|
29
|
-
export interface CompanyTombstone {
|
|
30
|
-
deletedAt: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Fetch the company's FILE_TOMBSTONE rows from hq-pro (GET /v1/files/tombstones)
|
|
35
|
-
* and return them as a POSIX-keyed map the planners consult to avoid
|
|
36
|
-
* resurrecting an intentionally-deleted object (delete-resync). The endpoint is
|
|
37
|
-
* ACL-filtered server-side, so the map only ever contains keys this caller can
|
|
38
|
-
* read — exactly the keys that can appear in the (STS-scoped) remote LIST.
|
|
39
|
-
*
|
|
40
|
-
* Best-effort and bounded by a 5s timeout: a tombstone read that fails, times
|
|
41
|
-
* out, or returns non-2xx degrades to an EMPTY map — i.e. to the pre-fix
|
|
42
|
-
* behavior (no suppression). That is the safe failure direction: a missed
|
|
43
|
-
* tombstone re-pulls/re-pushes a deleted file (a known, recoverable annoyance),
|
|
44
|
-
* whereas a spurious tombstone would HIDE a file the user wants. The failure is
|
|
45
|
-
* logged (never silently swallowed) so a persistently-degraded read is visible.
|
|
46
|
-
*/
|
|
47
|
-
export async function fetchCompanyTombstones(
|
|
48
|
-
vaultConfig: VaultServiceConfig,
|
|
49
|
-
companyUid: string,
|
|
50
|
-
): Promise<Map<string, CompanyTombstone>> {
|
|
51
|
-
const out = new Map<string, CompanyTombstone>();
|
|
52
|
-
try {
|
|
53
|
-
const token =
|
|
54
|
-
typeof vaultConfig.authToken === "function"
|
|
55
|
-
? await vaultConfig.authToken()
|
|
56
|
-
: vaultConfig.authToken;
|
|
57
|
-
const base = vaultConfig.apiUrl.replace(/\/+$/, "");
|
|
58
|
-
const url = `${base}/v1/files/tombstones?company=${encodeURIComponent(
|
|
59
|
-
companyUid,
|
|
60
|
-
)}`;
|
|
61
|
-
const controller = new AbortController();
|
|
62
|
-
const timer = setTimeout(
|
|
63
|
-
() => controller.abort(),
|
|
64
|
-
FETCH_TOMBSTONES_TIMEOUT_MS,
|
|
65
|
-
);
|
|
66
|
-
try {
|
|
67
|
-
const res = await fetch(url, {
|
|
68
|
-
method: "GET",
|
|
69
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
70
|
-
signal: controller.signal,
|
|
71
|
-
});
|
|
72
|
-
if (!res.ok) {
|
|
73
|
-
// Non-2xx is non-fatal: log and degrade to no-suppression. A 404 means
|
|
74
|
-
// the endpoint is not deployed yet (hq-pro release lag) — the sync
|
|
75
|
-
// proceeds with the legacy behavior until it lands.
|
|
76
|
-
console.error(
|
|
77
|
-
`[hq-sync] tombstone fetch returned ${res.status} (degrading to no-suppression)`,
|
|
78
|
-
);
|
|
79
|
-
return out;
|
|
80
|
-
}
|
|
81
|
-
const body = (await res.json()) as {
|
|
82
|
-
tombstones?: Array<{ key?: string; deletedAt?: string }>;
|
|
83
|
-
};
|
|
84
|
-
for (const t of body.tombstones ?? []) {
|
|
85
|
-
if (typeof t.key === "string" && typeof t.deletedAt === "string") {
|
|
86
|
-
out.set(toPosixKey(t.key), { deletedAt: t.deletedAt });
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
} finally {
|
|
90
|
-
clearTimeout(timer);
|
|
91
|
-
}
|
|
92
|
-
} catch (err) {
|
|
93
|
-
// Best-effort: a failed tombstone read must never break the sync. Log
|
|
94
|
-
// (policy: never silently swallow) and degrade to no-suppression.
|
|
95
|
-
try {
|
|
96
|
-
console.error(
|
|
97
|
-
`[hq-sync] tombstone fetch failed (non-fatal, degrading to no-suppression): ${
|
|
98
|
-
err instanceof Error ? err.message : String(err)
|
|
99
|
-
}`,
|
|
100
|
-
);
|
|
101
|
-
} catch {
|
|
102
|
-
// swallow — logging must never break sync
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return out;
|
|
106
|
-
}
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression guard: the watch + event-push path must NOT mint phantom
|
|
3
|
-
* `.conflict-*` mirrors for an ordinary single-writer local edit.
|
|
4
|
-
*
|
|
5
|
-
* Background (feedback_ef2b7c8c): a user editing files under
|
|
6
|
-
* `companies/{slug}/` saw the menubar's watch/event-push runner mint a
|
|
7
|
-
* `.conflict-<ts>-<machine>` mirror on EVERY ordinary local Edit/Write —
|
|
8
|
-
* thousands of phantom files in a single session. The watch/event-push runner
|
|
9
|
-
* reacts to a local change by running a targeted `--direction push` pass, i.e.
|
|
10
|
-
* `share()` over the changed company subtree, so the conflict-minting logic is
|
|
11
|
-
* share()'s push-side conflict gate. The original gate flagged a conflict on
|
|
12
|
-
* `localChanged` alone (`journalEntry.hash !== localHash`), which fires for
|
|
13
|
-
* every edit of any already-synced file. The gate now requires
|
|
14
|
-
* `(localChanged && remoteChanged) || isFreshCollision`: a single writer never
|
|
15
|
-
* has `remoteChanged` (the live S3 ETag still equals the journal's recorded
|
|
16
|
-
* baseline), so an ordinary edit is uploaded, not mirrored.
|
|
17
|
-
*
|
|
18
|
-
* This test pins that contract end-to-end through `share()` using the same
|
|
19
|
-
* mocked-S3 harness as `share.test.ts`, modelling a single-writer remote whose
|
|
20
|
-
* ETag is always exactly what THIS device last uploaded. A positive control
|
|
21
|
-
* (a genuine out-of-band peer write) proves the guard is not trivially
|
|
22
|
-
* always-zero: real divergence is still detected.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
26
|
-
import * as fs from "fs";
|
|
27
|
-
import * as path from "path";
|
|
28
|
-
import * as os from "os";
|
|
29
|
-
import { clearContextCache } from "../context.js";
|
|
30
|
-
import type { VaultServiceConfig } from "../types.js";
|
|
31
|
-
|
|
32
|
-
vi.mock("../s3.js", () => ({
|
|
33
|
-
toPosixKey: (key: string) => key.split("\\").join("/"),
|
|
34
|
-
uploadFile: vi.fn(),
|
|
35
|
-
uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
|
|
36
|
-
downloadFile: vi.fn().mockResolvedValue(undefined),
|
|
37
|
-
listRemoteFiles: vi.fn().mockResolvedValue([]),
|
|
38
|
-
deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
|
|
39
|
-
headRemoteFile: vi.fn(),
|
|
40
|
-
primeObjectTransport: vi.fn().mockResolvedValue(undefined),
|
|
41
|
-
primeUploads: vi.fn().mockResolvedValue(undefined),
|
|
42
|
-
}));
|
|
43
|
-
|
|
44
|
-
vi.mock("readline", () => ({
|
|
45
|
-
createInterface: vi.fn(() => ({ question: vi.fn(), close: vi.fn() })),
|
|
46
|
-
}));
|
|
47
|
-
|
|
48
|
-
import { share } from "./share.js";
|
|
49
|
-
import { downloadFile, headRemoteFile, uploadFile } from "../s3.js";
|
|
50
|
-
|
|
51
|
-
const mockConfig: VaultServiceConfig = {
|
|
52
|
-
apiUrl: "https://vault-api.test",
|
|
53
|
-
authToken: "test-jwt-token",
|
|
54
|
-
region: "us-east-1",
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const mockEntity = {
|
|
58
|
-
uid: "cmp_01ABCDEF",
|
|
59
|
-
slug: "acme",
|
|
60
|
-
bucketName: "hq-vault-acme-123",
|
|
61
|
-
status: "active",
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
function setupFetchMock() {
|
|
65
|
-
vi.stubGlobal(
|
|
66
|
-
"fetch",
|
|
67
|
-
vi.fn().mockImplementation(async (url: string) => {
|
|
68
|
-
const u = String(url);
|
|
69
|
-
if (u.includes("/entity/check-slug/me"))
|
|
70
|
-
return {
|
|
71
|
-
ok: true,
|
|
72
|
-
status: 200,
|
|
73
|
-
json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
|
|
74
|
-
text: async () => "",
|
|
75
|
-
};
|
|
76
|
-
if (u.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(u))
|
|
77
|
-
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
78
|
-
if (u.includes("/sts/vend"))
|
|
79
|
-
return {
|
|
80
|
-
ok: true,
|
|
81
|
-
status: 200,
|
|
82
|
-
json: async () => ({
|
|
83
|
-
credentials: {
|
|
84
|
-
accessKeyId: "ASIA_TEST",
|
|
85
|
-
secretAccessKey: "s",
|
|
86
|
-
sessionToken: "t",
|
|
87
|
-
expiration: new Date(Date.now() + 9e5).toISOString(),
|
|
88
|
-
},
|
|
89
|
-
expiresAt: new Date(Date.now() + 9e5).toISOString(),
|
|
90
|
-
}),
|
|
91
|
-
text: async () => "",
|
|
92
|
-
};
|
|
93
|
-
return { ok: false, status: 404, text: async () => "Not found" };
|
|
94
|
-
}),
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Every `.conflict-<ts>-*` mirror file anywhere under `root`. */
|
|
99
|
-
function countConflictMirrors(root: string): string[] {
|
|
100
|
-
const out: string[] = [];
|
|
101
|
-
const walk = (d: string) => {
|
|
102
|
-
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
103
|
-
const p = path.join(d, e.name);
|
|
104
|
-
if (e.isDirectory()) walk(p);
|
|
105
|
-
else if (/\.conflict-/.test(e.name)) out.push(p);
|
|
106
|
-
}
|
|
107
|
-
};
|
|
108
|
-
walk(root);
|
|
109
|
-
return out;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* In-memory single-writer S3: the object's ETag is always exactly what THIS
|
|
114
|
-
* device last uploaded (no peer ever writes). `lastModified` advances on each
|
|
115
|
-
* PUT to model S3 stamping it server-side. The S3-module signatures are
|
|
116
|
-
* `uploadFile(ctx, localPath, key)`, `headRemoteFile(ctx, key)`,
|
|
117
|
-
* `downloadFile(ctx, key, dest)` — wire the mocks to match exactly.
|
|
118
|
-
*/
|
|
119
|
-
function wireSingleWriterRemote() {
|
|
120
|
-
const remote = new Map<string, { etag: string; lastModified: Date; size: number }>();
|
|
121
|
-
let clock = Date.now();
|
|
122
|
-
let seq = 0;
|
|
123
|
-
vi.mocked(uploadFile).mockImplementation(async (_ctx: unknown, localPath: string, key: string) => {
|
|
124
|
-
const etag = `"etag-${seq++}"`;
|
|
125
|
-
clock += 1000;
|
|
126
|
-
remote.set(key, { etag, lastModified: new Date(clock), size: fs.statSync(localPath).size });
|
|
127
|
-
return { etag };
|
|
128
|
-
});
|
|
129
|
-
vi.mocked(headRemoteFile).mockImplementation(async (_ctx: unknown, key: string) => {
|
|
130
|
-
const r = remote.get(key);
|
|
131
|
-
return r ? { lastModified: r.lastModified, etag: r.etag, size: r.size } : null;
|
|
132
|
-
});
|
|
133
|
-
vi.mocked(downloadFile).mockImplementation(async (_ctx: unknown, _key: string, dest: string) => {
|
|
134
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
135
|
-
fs.writeFileSync(dest, "remote-bytes\n");
|
|
136
|
-
return {};
|
|
137
|
-
});
|
|
138
|
-
return remote;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
describe("watch + event-push conflict regression (feedback_ef2b7c8c)", () => {
|
|
142
|
-
let tmpDir: string;
|
|
143
|
-
let stateDir: string;
|
|
144
|
-
|
|
145
|
-
beforeEach(() => {
|
|
146
|
-
clearContextCache();
|
|
147
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-conflict-"));
|
|
148
|
-
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-state-"));
|
|
149
|
-
process.env.HQ_STATE_DIR = stateDir;
|
|
150
|
-
setupFetchMock();
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
afterEach(() => {
|
|
154
|
-
vi.unstubAllGlobals();
|
|
155
|
-
vi.clearAllMocks();
|
|
156
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
157
|
-
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
158
|
-
delete process.env.HQ_STATE_DIR;
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("a single writer's repeated local edits mint ZERO .conflict-* files", async () => {
|
|
162
|
-
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
163
|
-
fs.mkdirSync(companyRoot, { recursive: true });
|
|
164
|
-
const files = ["notes.md", "plan.md", "ideas.md"].map((n) => path.join(companyRoot, n));
|
|
165
|
-
for (const f of files) fs.writeFileSync(f, "v0\n");
|
|
166
|
-
|
|
167
|
-
wireSingleWriterRemote();
|
|
168
|
-
|
|
169
|
-
const conflictPathsSeen: string[] = [];
|
|
170
|
-
// The event-push runner pushes the changed company subtree. Mirror that:
|
|
171
|
-
// share() over the company root on every settled change.
|
|
172
|
-
const eventPush = async () => {
|
|
173
|
-
const r = await share({
|
|
174
|
-
paths: [companyRoot],
|
|
175
|
-
company: "acme",
|
|
176
|
-
vaultConfig: mockConfig,
|
|
177
|
-
hqRoot: tmpDir,
|
|
178
|
-
onConflict: "keep",
|
|
179
|
-
onEvent: () => {},
|
|
180
|
-
});
|
|
181
|
-
conflictPathsSeen.push(...r.conflictPaths);
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
// Initial sync establishes the journal baseline (first upload of each file).
|
|
185
|
-
await eventPush();
|
|
186
|
-
|
|
187
|
-
// An editing session: 30 ordinary local edits, round-robin across the
|
|
188
|
-
// files, each followed by its targeted event-push.
|
|
189
|
-
for (let i = 0; i < 30; i++) {
|
|
190
|
-
fs.writeFileSync(files[i % files.length], `v${i + 1}\n`);
|
|
191
|
-
await eventPush();
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
expect(countConflictMirrors(tmpDir)).toEqual([]);
|
|
195
|
-
expect(conflictPathsSeen).toEqual([]);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("positive control: a genuine peer write is still detected as a conflict", async () => {
|
|
199
|
-
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
200
|
-
fs.mkdirSync(companyRoot, { recursive: true });
|
|
201
|
-
const file = path.join(companyRoot, "contested.md");
|
|
202
|
-
fs.writeFileSync(file, "v0\n");
|
|
203
|
-
|
|
204
|
-
const remote = wireSingleWriterRemote();
|
|
205
|
-
|
|
206
|
-
const conflictPathsSeen: string[] = [];
|
|
207
|
-
const eventPush = async () => {
|
|
208
|
-
const r = await share({
|
|
209
|
-
paths: [companyRoot],
|
|
210
|
-
company: "acme",
|
|
211
|
-
vaultConfig: mockConfig,
|
|
212
|
-
hqRoot: tmpDir,
|
|
213
|
-
onConflict: "keep",
|
|
214
|
-
onEvent: () => {},
|
|
215
|
-
});
|
|
216
|
-
conflictPathsSeen.push(...r.conflictPaths);
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
await eventPush(); // baseline
|
|
220
|
-
|
|
221
|
-
// A peer advances the remote object out-of-band to DIFFERENT bytes. Journal
|
|
222
|
-
// keys are company-relative, so the key is "contested.md".
|
|
223
|
-
remote.set("contested.md", { etag: '"peer-etag"', lastModified: new Date(Date.now() + 60_000), size: 99 });
|
|
224
|
-
// ...and we also edit locally → both sides moved → a genuine conflict.
|
|
225
|
-
fs.writeFileSync(file, "v1-local\n");
|
|
226
|
-
await eventPush();
|
|
227
|
-
|
|
228
|
-
// Existing-entry push conflicts surface via conflictPaths (the inspection
|
|
229
|
-
// mirror is written by the pull leg). The contract under test: a REAL
|
|
230
|
-
// divergence is still flagged — the single-writer guard above did not
|
|
231
|
-
// over-correct into swallowing true conflicts.
|
|
232
|
-
expect(conflictPathsSeen).toContain("contested.md");
|
|
233
|
-
});
|
|
234
|
-
});
|
package/src/client-info.test.ts
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the client-info helpers and header injection.
|
|
3
|
-
*
|
|
4
|
-
* Two layers are exercised here:
|
|
5
|
-
* 1. The pure functions in `client-info.ts` — buildClientHeaders,
|
|
6
|
-
* clientInfoFromPackage, detectHqCoreVersion.
|
|
7
|
-
* 2. End-to-end injection into VaultClient.request, since "the headers
|
|
8
|
-
* actually land on outbound fetch" is the property consumers care about.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
|
-
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
import {
|
|
16
|
-
buildClientHeaders,
|
|
17
|
-
clientInfoFromPackage,
|
|
18
|
-
detectHqCoreVersion,
|
|
19
|
-
HEADER_CLIENT_NAME,
|
|
20
|
-
HEADER_CLIENT_VERSION,
|
|
21
|
-
HEADER_HQ_CORE_VERSION,
|
|
22
|
-
} from "./client-info.js";
|
|
23
|
-
import { VaultClient } from "./vault-client.js";
|
|
24
|
-
|
|
25
|
-
describe("buildClientHeaders", () => {
|
|
26
|
-
it("returns empty object when info is undefined", () => {
|
|
27
|
-
expect(buildClientHeaders(undefined)).toEqual({});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("emits User-Agent + x-hq-client-{name,version} from name/version", () => {
|
|
31
|
-
const headers = buildClientHeaders({
|
|
32
|
-
name: "@indigoai-us/hq-cli",
|
|
33
|
-
version: "5.15.0",
|
|
34
|
-
});
|
|
35
|
-
expect(headers["User-Agent"]).toBe("@indigoai-us/hq-cli/5.15.0");
|
|
36
|
-
expect(headers[HEADER_CLIENT_NAME]).toBe("@indigoai-us/hq-cli");
|
|
37
|
-
expect(headers[HEADER_CLIENT_VERSION]).toBe("5.15.0");
|
|
38
|
-
expect(headers[HEADER_HQ_CORE_VERSION]).toBeUndefined();
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("includes x-hq-core-version only when hqCoreVersion is set", () => {
|
|
42
|
-
const headers = buildClientHeaders({
|
|
43
|
-
name: "@indigoai-us/hq-cli",
|
|
44
|
-
version: "5.15.0",
|
|
45
|
-
hqCoreVersion: "14.1.0",
|
|
46
|
-
});
|
|
47
|
-
expect(headers[HEADER_HQ_CORE_VERSION]).toBe("14.1.0");
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("emits arbitrary extra fields as x-hq-client-<key> headers", () => {
|
|
51
|
-
const headers = buildClientHeaders({
|
|
52
|
-
name: "x",
|
|
53
|
-
version: "1.0.0",
|
|
54
|
-
extra: { machine: "ec2-bot-7", channel: "stable" },
|
|
55
|
-
});
|
|
56
|
-
expect(headers["x-hq-client-machine"]).toBe("ec2-bot-7");
|
|
57
|
-
expect(headers["x-hq-client-channel"]).toBe("stable");
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
describe("clientInfoFromPackage", () => {
|
|
62
|
-
it("extracts name and version from a parsed package.json", () => {
|
|
63
|
-
expect(clientInfoFromPackage({ name: "foo", version: "1.2.3" })).toEqual({
|
|
64
|
-
name: "foo",
|
|
65
|
-
version: "1.2.3",
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("throws when name is missing", () => {
|
|
70
|
-
expect(() => clientInfoFromPackage({ version: "1.0.0" })).toThrow(/name/);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("throws when version is missing", () => {
|
|
74
|
-
expect(() => clientInfoFromPackage({ name: "foo" })).toThrow(/version/);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("throws on non-string fields", () => {
|
|
78
|
-
expect(() =>
|
|
79
|
-
clientInfoFromPackage({ name: 42 as unknown as string, version: "1.0.0" }),
|
|
80
|
-
).toThrow();
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
describe("detectHqCoreVersion", () => {
|
|
85
|
-
let tmpRoot: string;
|
|
86
|
-
const origHqHome = process.env.HQ_HOME;
|
|
87
|
-
const origHqRoot = process.env.HQ_ROOT;
|
|
88
|
-
|
|
89
|
-
beforeEach(() => {
|
|
90
|
-
tmpRoot = mkdtempSync(join(tmpdir(), "hq-core-detect-"));
|
|
91
|
-
delete process.env.HQ_HOME;
|
|
92
|
-
delete process.env.HQ_ROOT;
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
afterEach(() => {
|
|
96
|
-
rmSync(tmpRoot, { recursive: true, force: true });
|
|
97
|
-
if (origHqHome !== undefined) process.env.HQ_HOME = origHqHome;
|
|
98
|
-
if (origHqRoot !== undefined) process.env.HQ_ROOT = origHqRoot;
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
function seedHqCore(root: string, version: string): void {
|
|
102
|
-
mkdirSync(join(root, "core"), { recursive: true });
|
|
103
|
-
mkdirSync(join(root, "companies"), { recursive: true });
|
|
104
|
-
writeFileSync(
|
|
105
|
-
join(root, "core", "core.yaml"),
|
|
106
|
-
`version: 1\nhqVersion: "${version}"\nupdatedAt: "2026-05-13T00:00:00Z"\n`,
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
it("returns undefined when nothing on the walk-up has core.yaml", () => {
|
|
111
|
-
expect(detectHqCoreVersion(tmpRoot)).toBeUndefined();
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("returns hqVersion when startDir is the hq-core root", () => {
|
|
115
|
-
seedHqCore(tmpRoot, "14.1.0");
|
|
116
|
-
expect(detectHqCoreVersion(tmpRoot)).toBe("14.1.0");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("walks upward to find core.yaml from a nested cwd", () => {
|
|
120
|
-
seedHqCore(tmpRoot, "14.2.0");
|
|
121
|
-
const nested = join(tmpRoot, "companies", "acme", "projects", "p1");
|
|
122
|
-
mkdirSync(nested, { recursive: true });
|
|
123
|
-
expect(detectHqCoreVersion(nested)).toBe("14.2.0");
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("ignores directories that have core.yaml but no companies/ — disambiguates fixtures", () => {
|
|
127
|
-
mkdirSync(join(tmpRoot, "core"), { recursive: true });
|
|
128
|
-
writeFileSync(
|
|
129
|
-
join(tmpRoot, "core", "core.yaml"),
|
|
130
|
-
`version: 1\nhqVersion: "99.0.0"\n`,
|
|
131
|
-
);
|
|
132
|
-
// No companies/ at this level → not an hq-core root.
|
|
133
|
-
expect(detectHqCoreVersion(tmpRoot)).toBeUndefined();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("honors HQ_HOME env override before walking cwd", () => {
|
|
137
|
-
seedHqCore(tmpRoot, "14.3.0");
|
|
138
|
-
process.env.HQ_HOME = tmpRoot;
|
|
139
|
-
// startDir intentionally points elsewhere — env should win.
|
|
140
|
-
const elsewhere = mkdtempSync(join(tmpdir(), "elsewhere-"));
|
|
141
|
-
try {
|
|
142
|
-
expect(detectHqCoreVersion(elsewhere)).toBe("14.3.0");
|
|
143
|
-
} finally {
|
|
144
|
-
rmSync(elsewhere, { recursive: true, force: true });
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it("parses unquoted hqVersion values too", () => {
|
|
149
|
-
mkdirSync(join(tmpRoot, "core"), { recursive: true });
|
|
150
|
-
mkdirSync(join(tmpRoot, "companies"), { recursive: true });
|
|
151
|
-
writeFileSync(
|
|
152
|
-
join(tmpRoot, "core", "core.yaml"),
|
|
153
|
-
`version: 1\nhqVersion: 14.4.0\n`,
|
|
154
|
-
);
|
|
155
|
-
expect(detectHqCoreVersion(tmpRoot)).toBe("14.4.0");
|
|
156
|
-
});
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
describe("VaultClient stamps client headers on outbound requests", () => {
|
|
160
|
-
afterEach(() => {
|
|
161
|
-
vi.restoreAllMocks();
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("includes x-hq-client-name + x-hq-client-version when clientInfo is set", async () => {
|
|
165
|
-
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
166
|
-
new Response(JSON.stringify({ memberships: [] }), {
|
|
167
|
-
status: 200,
|
|
168
|
-
headers: { "Content-Type": "application/json" },
|
|
169
|
-
}),
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
const client = new VaultClient({
|
|
173
|
-
apiUrl: "https://vault.test.example.com",
|
|
174
|
-
authToken: "tok",
|
|
175
|
-
clientInfo: {
|
|
176
|
-
name: "@indigoai-us/hq-cli",
|
|
177
|
-
version: "5.15.0",
|
|
178
|
-
hqCoreVersion: "14.1.0",
|
|
179
|
-
},
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
await client.listMyMemberships();
|
|
183
|
-
|
|
184
|
-
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
185
|
-
const init = fetchSpy.mock.calls[0]?.[1] as RequestInit;
|
|
186
|
-
const headers = init.headers as Record<string, string>;
|
|
187
|
-
expect(headers[HEADER_CLIENT_NAME]).toBe("@indigoai-us/hq-cli");
|
|
188
|
-
expect(headers[HEADER_CLIENT_VERSION]).toBe("5.15.0");
|
|
189
|
-
expect(headers[HEADER_HQ_CORE_VERSION]).toBe("14.1.0");
|
|
190
|
-
expect(headers["User-Agent"]).toBe("@indigoai-us/hq-cli/5.15.0");
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it("omits client headers when clientInfo is not set — back-compat", async () => {
|
|
194
|
-
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
195
|
-
new Response(JSON.stringify({ memberships: [] }), {
|
|
196
|
-
status: 200,
|
|
197
|
-
headers: { "Content-Type": "application/json" },
|
|
198
|
-
}),
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
const client = new VaultClient({
|
|
202
|
-
apiUrl: "https://vault.test.example.com",
|
|
203
|
-
authToken: "tok",
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
await client.listMyMemberships();
|
|
207
|
-
|
|
208
|
-
const headers = (fetchSpy.mock.calls[0]?.[1] as RequestInit)
|
|
209
|
-
.headers as Record<string, string>;
|
|
210
|
-
expect(headers[HEADER_CLIENT_NAME]).toBeUndefined();
|
|
211
|
-
expect(headers[HEADER_CLIENT_VERSION]).toBeUndefined();
|
|
212
|
-
expect(headers["User-Agent"]).toBeUndefined();
|
|
213
|
-
});
|
|
214
|
-
});
|
package/src/client-info.ts
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client identification — every HQ client that talks to hq-cloud-api should
|
|
3
|
-
* stamp its name and version on outbound requests so the server can attribute
|
|
4
|
-
* traffic, gate on minimum versions, and surface deprecation warnings.
|
|
5
|
-
*
|
|
6
|
-
* The CLI in particular sends a third header (`x-hq-core-version`) when it's
|
|
7
|
-
* invoked from inside an hq-core checkout, so the server sees which scaffold
|
|
8
|
-
* generation the user is running against.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { readFileSync } from "node:fs";
|
|
12
|
-
import { dirname, join, resolve } from "node:path";
|
|
13
|
-
import type { ClientInfo } from "./types.js";
|
|
14
|
-
|
|
15
|
-
export const HEADER_CLIENT_NAME = "x-hq-client-name";
|
|
16
|
-
export const HEADER_CLIENT_VERSION = "x-hq-client-version";
|
|
17
|
-
export const HEADER_HQ_CORE_VERSION = "x-hq-core-version";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Build the set of `x-hq-*` headers (plus a derived `User-Agent`) for a
|
|
21
|
-
* ClientInfo. Returns an empty object when info is undefined so callers can
|
|
22
|
-
* spread the result unconditionally.
|
|
23
|
-
*/
|
|
24
|
-
export function buildClientHeaders(
|
|
25
|
-
info: ClientInfo | undefined,
|
|
26
|
-
): Record<string, string> {
|
|
27
|
-
if (!info) return {};
|
|
28
|
-
|
|
29
|
-
const headers: Record<string, string> = {
|
|
30
|
-
"User-Agent": `${info.name}/${info.version}`,
|
|
31
|
-
[HEADER_CLIENT_NAME]: info.name,
|
|
32
|
-
[HEADER_CLIENT_VERSION]: info.version,
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
if (info.hqCoreVersion) {
|
|
36
|
-
headers[HEADER_HQ_CORE_VERSION] = info.hqCoreVersion;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (info.extra) {
|
|
40
|
-
for (const [k, v] of Object.entries(info.extra)) {
|
|
41
|
-
headers[`x-hq-client-${k}`] = v;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return headers;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Build a ClientInfo from a parsed package.json. Most consumers will call this
|
|
50
|
-
* once at startup and pass the result into every VaultServiceConfig.
|
|
51
|
-
*/
|
|
52
|
-
export function clientInfoFromPackage(pkg: {
|
|
53
|
-
name?: unknown;
|
|
54
|
-
version?: unknown;
|
|
55
|
-
}): ClientInfo {
|
|
56
|
-
if (typeof pkg.name !== "string" || pkg.name.length === 0) {
|
|
57
|
-
throw new Error("clientInfoFromPackage: package.json is missing a name");
|
|
58
|
-
}
|
|
59
|
-
if (typeof pkg.version !== "string" || pkg.version.length === 0) {
|
|
60
|
-
throw new Error("clientInfoFromPackage: package.json is missing a version");
|
|
61
|
-
}
|
|
62
|
-
return { name: pkg.name, version: pkg.version };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Walk upward from `startDir` looking for `core/core.yaml`. When found, parse
|
|
67
|
-
* out the `hqVersion` field and return it. Returns undefined if we never find
|
|
68
|
-
* an hq-core checkout — i.e. the caller is running from a plain user dir.
|
|
69
|
-
*
|
|
70
|
-
* Honors `HQ_HOME` env var as an explicit override so multi-checkout setups
|
|
71
|
-
* (e.g. CI bots, the menubar app pointing at a non-cwd HQ root) can pin the
|
|
72
|
-
* detection without relying on cwd.
|
|
73
|
-
*
|
|
74
|
-
* Why a regex instead of a YAML parser: `core.yaml` lives at the very top of
|
|
75
|
-
* the file and the field has a stable shape — adding a YAML dep just to read
|
|
76
|
-
* one string would balloon every consumer's bundle. The current shape is
|
|
77
|
-
* `hqVersion: "X.Y.Z"` (quoted) per the canonical seed; we tolerate unquoted
|
|
78
|
-
* too.
|
|
79
|
-
*/
|
|
80
|
-
export function detectHqCoreVersion(startDir?: string): string | undefined {
|
|
81
|
-
const fromEnv = process.env.HQ_HOME ?? process.env.HQ_ROOT;
|
|
82
|
-
if (fromEnv) {
|
|
83
|
-
const v = readCoreVersionAt(fromEnv);
|
|
84
|
-
if (v) return v;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
let dir = resolve(startDir ?? process.cwd());
|
|
88
|
-
while (true) {
|
|
89
|
-
const v = readCoreVersionAt(dir);
|
|
90
|
-
if (v) return v;
|
|
91
|
-
const parent = dirname(dir);
|
|
92
|
-
if (parent === dir) return undefined;
|
|
93
|
-
dir = parent;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function readCoreVersionAt(hqRoot: string): string | undefined {
|
|
98
|
-
// hq-core identity requires BOTH core/core.yaml AND companies/ — matches the
|
|
99
|
-
// CLI's own detection (commit bc827d0). Without this guard, any directory
|
|
100
|
-
// containing a stray `core/core.yaml` (e.g. a test fixture) would be
|
|
101
|
-
// misidentified as an hq-core root.
|
|
102
|
-
const yamlPath = join(hqRoot, "core", "core.yaml");
|
|
103
|
-
const companiesPath = join(hqRoot, "companies");
|
|
104
|
-
let content: string;
|
|
105
|
-
try {
|
|
106
|
-
content = readFileSync(yamlPath, "utf8");
|
|
107
|
-
} catch {
|
|
108
|
-
return undefined;
|
|
109
|
-
}
|
|
110
|
-
try {
|
|
111
|
-
// statSync would be marginally cleaner, but readdirSync of a nonexistent
|
|
112
|
-
// path throws synchronously which is what we want.
|
|
113
|
-
readFileSync(companiesPath, { flag: "r" });
|
|
114
|
-
} catch (err) {
|
|
115
|
-
const e = err as NodeJS.ErrnoException;
|
|
116
|
-
// EISDIR is the success case — companies/ exists and is a directory.
|
|
117
|
-
if (e.code !== "EISDIR") return undefined;
|
|
118
|
-
}
|
|
119
|
-
const match = /^hqVersion:\s*["']?([^"'\s]+)/m.exec(content);
|
|
120
|
-
return match ? match[1] : undefined;
|
|
121
|
-
}
|