@indigoai-us/hq-cloud 5.1.0 → 5.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/dist/bin/sync-runner.d.ts +111 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -0
  3. package/dist/bin/sync-runner.js +285 -0
  4. package/dist/bin/sync-runner.js.map +1 -0
  5. package/dist/bin/sync-runner.test.d.ts +10 -0
  6. package/dist/bin/sync-runner.test.d.ts.map +1 -0
  7. package/dist/bin/sync-runner.test.js +492 -0
  8. package/dist/bin/sync-runner.test.js.map +1 -0
  9. package/dist/cli/index.d.ts +1 -1
  10. package/dist/cli/index.d.ts.map +1 -1
  11. package/dist/cli/share.js +2 -2
  12. package/dist/cli/share.js.map +1 -1
  13. package/dist/cli/share.test.js +9 -1
  14. package/dist/cli/share.test.js.map +1 -1
  15. package/dist/cli/sync.d.ts +28 -0
  16. package/dist/cli/sync.d.ts.map +1 -1
  17. package/dist/cli/sync.js +33 -10
  18. package/dist/cli/sync.js.map +1 -1
  19. package/dist/cli/sync.test.js +15 -4
  20. package/dist/cli/sync.test.js.map +1 -1
  21. package/dist/cognito-auth.d.ts.map +1 -1
  22. package/dist/cognito-auth.js +19 -1
  23. package/dist/cognito-auth.js.map +1 -1
  24. package/dist/cognito-auth.test.d.ts +9 -0
  25. package/dist/cognito-auth.test.d.ts.map +1 -0
  26. package/dist/cognito-auth.test.js +113 -0
  27. package/dist/cognito-auth.test.js.map +1 -0
  28. package/dist/context.d.ts.map +1 -1
  29. package/dist/context.js +1 -0
  30. package/dist/context.js.map +1 -1
  31. package/dist/daemon-worker.d.ts +6 -1
  32. package/dist/daemon-worker.d.ts.map +1 -1
  33. package/dist/daemon-worker.js +12 -16
  34. package/dist/daemon-worker.js.map +1 -1
  35. package/dist/daemon.d.ts +2 -0
  36. package/dist/daemon.d.ts.map +1 -1
  37. package/dist/daemon.js +2 -0
  38. package/dist/daemon.js.map +1 -1
  39. package/dist/ignore.d.ts +13 -2
  40. package/dist/ignore.d.ts.map +1 -1
  41. package/dist/ignore.js +69 -12
  42. package/dist/ignore.js.map +1 -1
  43. package/dist/index.d.ts +24 -28
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +19 -134
  46. package/dist/index.js.map +1 -1
  47. package/dist/journal.d.ts +20 -4
  48. package/dist/journal.d.ts.map +1 -1
  49. package/dist/journal.js +45 -8
  50. package/dist/journal.js.map +1 -1
  51. package/dist/journal.test.d.ts +9 -0
  52. package/dist/journal.test.d.ts.map +1 -0
  53. package/dist/journal.test.js +114 -0
  54. package/dist/journal.test.js.map +1 -0
  55. package/dist/s3.d.ts +18 -6
  56. package/dist/s3.d.ts.map +1 -1
  57. package/dist/s3.js +57 -56
  58. package/dist/s3.js.map +1 -1
  59. package/dist/types.d.ts +34 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/vault-client.d.ts +16 -0
  62. package/dist/vault-client.d.ts.map +1 -1
  63. package/dist/vault-client.js +19 -0
  64. package/dist/vault-client.js.map +1 -1
  65. package/dist/vault-client.test.js +25 -0
  66. package/dist/vault-client.test.js.map +1 -1
  67. package/dist/watcher.d.ts +7 -1
  68. package/dist/watcher.d.ts.map +1 -1
  69. package/dist/watcher.js +11 -5
  70. package/dist/watcher.js.map +1 -1
  71. package/package.json +15 -3
  72. package/src/bin/sync-runner.test.ts +617 -0
  73. package/src/bin/sync-runner.ts +390 -0
  74. package/src/cli/accept.ts +97 -0
  75. package/src/cli/conflict.ts +119 -0
  76. package/src/cli/index.ts +25 -0
  77. package/src/cli/invite.test.ts +247 -0
  78. package/src/cli/invite.ts +180 -0
  79. package/src/cli/promote.ts +123 -0
  80. package/src/cli/share.test.ts +155 -0
  81. package/src/cli/share.ts +212 -0
  82. package/src/cli/sync.test.ts +225 -0
  83. package/src/cli/sync.ts +225 -0
  84. package/src/cognito-auth.test.ts +156 -0
  85. package/src/cognito-auth.ts +18 -1
  86. package/src/context.test.ts +202 -0
  87. package/src/context.ts +178 -0
  88. package/src/daemon-worker.ts +13 -19
  89. package/src/daemon.ts +2 -0
  90. package/src/ignore.ts +76 -12
  91. package/src/index.ts +93 -165
  92. package/src/journal.test.ts +146 -0
  93. package/src/journal.ts +53 -11
  94. package/src/s3.ts +76 -66
  95. package/src/types.ts +37 -0
  96. package/src/vault-client.test.ts +390 -0
  97. package/src/vault-client.ts +400 -0
  98. package/src/watcher.ts +12 -5
  99. package/test/invite-flow.integration.test.ts +244 -0
  100. package/test/share-sync.integration.test.ts +210 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * `hq share` command — selective push to entity vault (VLT-5 US-002).
3
+ *
4
+ * Broadcasts local file(s) to the company's S3 vault bucket.
5
+ * Refuses to overwrite a newer remote version without prompting.
6
+ */
7
+
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ import type { VaultServiceConfig } from "../types.js";
11
+ import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
12
+ import { uploadFile, headRemoteFile } from "../s3.js";
13
+ import { readJournal, writeJournal, hashFile, updateEntry } from "../journal.js";
14
+ import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
15
+ import { resolveConflict } from "./conflict.js";
16
+ import type { ConflictStrategy } from "./conflict.js";
17
+
18
+ export interface ShareOptions {
19
+ /** Path(s) to share (files or directories) */
20
+ paths: string[];
21
+ /** Company slug or UID (defaults to active company from config) */
22
+ company?: string;
23
+ /** Optional message attached to journal entries */
24
+ message?: string;
25
+ /** Non-interactive conflict strategy */
26
+ onConflict?: ConflictStrategy;
27
+ /** Vault service config */
28
+ vaultConfig: VaultServiceConfig;
29
+ /** HQ root directory */
30
+ hqRoot: string;
31
+ }
32
+
33
+ export interface ShareResult {
34
+ filesUploaded: number;
35
+ bytesUploaded: number;
36
+ filesSkipped: number;
37
+ aborted: boolean;
38
+ }
39
+
40
+ /**
41
+ * Share local file(s) to the entity vault.
42
+ */
43
+ export async function share(options: ShareOptions): Promise<ShareResult> {
44
+ const { paths, company, message, onConflict, vaultConfig, hqRoot } = options;
45
+
46
+ // Resolve company — slug, UID, or from active config
47
+ const companyRef = company ?? resolveActiveCompany(hqRoot);
48
+ if (!companyRef) {
49
+ throw new Error(
50
+ "No company specified and no active company found. " +
51
+ "Use --company <slug> or set up .hq/config.json.",
52
+ );
53
+ }
54
+
55
+ // Resolve entity context (handles STS vending + caching)
56
+ let ctx = await resolveEntityContext(companyRef, vaultConfig);
57
+ const shouldSync = createIgnoreFilter(hqRoot);
58
+ const journal = readJournal(ctx.slug);
59
+
60
+ let filesUploaded = 0;
61
+ let bytesUploaded = 0;
62
+ let filesSkipped = 0;
63
+
64
+ // Collect all files to share
65
+ const filesToShare = collectFiles(paths, hqRoot, shouldSync);
66
+
67
+ for (const { absolutePath, relativePath } of filesToShare) {
68
+ if (!isWithinSizeLimit(absolutePath)) {
69
+ console.error(` Skipped (too large): ${relativePath}`);
70
+ filesSkipped++;
71
+ continue;
72
+ }
73
+
74
+ // Auto-refresh context if credentials expiring
75
+ if (isExpiringSoon(ctx.expiresAt)) {
76
+ ctx = await refreshEntityContext(companyRef, vaultConfig);
77
+ }
78
+
79
+ // Check for remote conflict — refuse to overwrite newer remote version
80
+ const remoteMeta = await headRemoteFile(ctx, relativePath);
81
+ if (remoteMeta) {
82
+ const journalEntry = journal.files[relativePath];
83
+ const localHash = hashFile(absolutePath);
84
+
85
+ // If remote has changed since our last sync, it's a conflict
86
+ if (journalEntry && journalEntry.hash !== localHash) {
87
+ // Local has changes — check if remote also changed
88
+ const resolution = await resolveConflict(
89
+ {
90
+ path: relativePath,
91
+ localHash,
92
+ remoteModified: remoteMeta.lastModified,
93
+ direction: "push",
94
+ },
95
+ onConflict,
96
+ );
97
+
98
+ if (resolution === "abort") {
99
+ return { filesUploaded, bytesUploaded, filesSkipped, aborted: true };
100
+ }
101
+ if (resolution === "keep" || resolution === "skip") {
102
+ filesSkipped++;
103
+ continue;
104
+ }
105
+ // "overwrite" falls through to upload
106
+ }
107
+ }
108
+
109
+ // Upload
110
+ try {
111
+ const stat = fs.statSync(absolutePath);
112
+ const hash = hashFile(absolutePath);
113
+
114
+ await uploadFile(ctx, absolutePath, relativePath);
115
+
116
+ // Update journal with optional message
117
+ updateEntry(journal, relativePath, hash, stat.size, "up");
118
+ if (message) {
119
+ journal.files[relativePath] = {
120
+ ...journal.files[relativePath],
121
+ message,
122
+ } as typeof journal.files[string] & { message: string };
123
+ }
124
+
125
+ filesUploaded++;
126
+ bytesUploaded += stat.size;
127
+ console.log(` ✓ ${relativePath}`);
128
+ } catch (err) {
129
+ console.error(
130
+ ` ✗ ${relativePath} — ${err instanceof Error ? err.message : err}`,
131
+ );
132
+ }
133
+ }
134
+
135
+ writeJournal(ctx.slug, journal);
136
+
137
+ return { filesUploaded, bytesUploaded, filesSkipped, aborted: false };
138
+ }
139
+
140
+ /**
141
+ * Resolve active company from .hq/config.json or parent directory chain.
142
+ */
143
+ function resolveActiveCompany(hqRoot: string): string | undefined {
144
+ const configPath = path.join(hqRoot, ".hq", "config.json");
145
+ if (fs.existsSync(configPath)) {
146
+ try {
147
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
148
+ return config.activeCompany ?? config.companySlug;
149
+ } catch {
150
+ // Ignore parse errors
151
+ }
152
+ }
153
+ return undefined;
154
+ }
155
+
156
+ /**
157
+ * Collect files from paths (expanding directories recursively).
158
+ */
159
+ function collectFiles(
160
+ paths: string[],
161
+ hqRoot: string,
162
+ filter: (p: string) => boolean,
163
+ ): { absolutePath: string; relativePath: string }[] {
164
+ const results: { absolutePath: string; relativePath: string }[] = [];
165
+
166
+ for (const p of paths) {
167
+ const absolutePath = path.isAbsolute(p) ? p : path.resolve(hqRoot, p);
168
+
169
+ if (!fs.existsSync(absolutePath)) {
170
+ console.error(` Warning: ${p} does not exist, skipping.`);
171
+ continue;
172
+ }
173
+
174
+ const stat = fs.statSync(absolutePath);
175
+ if (stat.isDirectory()) {
176
+ results.push(...walkDir(absolutePath, hqRoot, filter));
177
+ } else if (stat.isFile()) {
178
+ const relativePath = path.relative(hqRoot, absolutePath);
179
+ if (filter(absolutePath)) {
180
+ results.push({ absolutePath, relativePath });
181
+ }
182
+ }
183
+ }
184
+
185
+ return results;
186
+ }
187
+
188
+ function walkDir(
189
+ dir: string,
190
+ root: string,
191
+ filter: (p: string) => boolean,
192
+ ): { absolutePath: string; relativePath: string }[] {
193
+ const results: { absolutePath: string; relativePath: string }[] = [];
194
+ if (!fs.existsSync(dir)) return results;
195
+
196
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
197
+ for (const entry of entries) {
198
+ const absolutePath = path.join(dir, entry.name);
199
+ if (!filter(absolutePath)) continue;
200
+
201
+ if (entry.isDirectory()) {
202
+ results.push(...walkDir(absolutePath, root, filter));
203
+ } else if (entry.isFile()) {
204
+ results.push({
205
+ absolutePath,
206
+ relativePath: path.relative(root, absolutePath),
207
+ });
208
+ }
209
+ }
210
+
211
+ return results;
212
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Unit tests for hq sync command (VLT-5 US-002).
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+ import * as os from "os";
9
+ import { clearContextCache } from "../context.js";
10
+ import type { VaultServiceConfig } from "../types.js";
11
+
12
+ // Mock s3 module at the top level
13
+ vi.mock("../s3.js", async () => {
14
+ const { vi: innerVi } = await import("vitest");
15
+ const innerFs = await import("fs");
16
+ const innerPath = await import("path");
17
+
18
+ const remoteFiles = [
19
+ { key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
20
+ { key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
21
+ ];
22
+
23
+ return {
24
+ uploadFile: innerVi.fn().mockResolvedValue(undefined),
25
+ downloadFile: innerVi.fn().mockImplementation(async (_ctx: unknown, _key: string, localPath: string) => {
26
+ const dir = innerPath.dirname(localPath);
27
+ if (!innerFs.existsSync(dir)) innerFs.mkdirSync(dir, { recursive: true });
28
+ innerFs.writeFileSync(localPath, "mock file content");
29
+ }),
30
+ listRemoteFiles: innerVi.fn().mockResolvedValue(remoteFiles),
31
+ deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
32
+ headRemoteFile: innerVi.fn().mockResolvedValue(null),
33
+ };
34
+ });
35
+
36
+ import { sync } from "./sync.js";
37
+
38
+ const mockConfig: VaultServiceConfig = {
39
+ apiUrl: "https://vault-api.test",
40
+ authToken: "test-jwt-token",
41
+ region: "us-east-1",
42
+ };
43
+
44
+ const mockEntity = {
45
+ uid: "cmp_01ABCDEF",
46
+ slug: "acme",
47
+ bucketName: "hq-vault-acme-123",
48
+ status: "active",
49
+ };
50
+
51
+ const mockVendResponse = {
52
+ credentials: {
53
+ accessKeyId: "ASIA_TEST_KEY",
54
+ secretAccessKey: "test-secret",
55
+ sessionToken: "test-session-token",
56
+ expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
57
+ },
58
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
59
+ };
60
+
61
+ function setupFetchMock() {
62
+ const fetchMock = vi.fn().mockImplementation(async (url: string) => {
63
+ const urlStr = String(url);
64
+ if (urlStr.includes("/entity/by-slug/")) {
65
+ return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
66
+ }
67
+ if (urlStr.includes("/sts/vend")) {
68
+ return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
69
+ }
70
+ return { ok: false, status: 404, text: async () => "Not found" };
71
+ });
72
+ vi.stubGlobal("fetch", fetchMock);
73
+ return fetchMock;
74
+ }
75
+
76
+ describe("sync", () => {
77
+ let tmpDir: string;
78
+ let stateDir: string;
79
+ let journalPath: string;
80
+
81
+ beforeEach(() => {
82
+ clearContextCache();
83
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-test-"));
84
+ // Journal moved to ~/.hq/sync-journal.{slug}.json (ADR-0001 Phase 5).
85
+ // Redirect to a tmp dir via HQ_STATE_DIR so the test doesn't pollute the
86
+ // user's real ~/.hq. mockEntity.slug is "acme".
87
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-test-"));
88
+ process.env.HQ_STATE_DIR = stateDir;
89
+ journalPath = path.join(stateDir, "sync-journal.acme.json");
90
+ setupFetchMock();
91
+ });
92
+
93
+ afterEach(() => {
94
+ vi.unstubAllGlobals();
95
+ vi.clearAllMocks();
96
+ fs.rmSync(tmpDir, { recursive: true, force: true });
97
+ fs.rmSync(stateDir, { recursive: true, force: true });
98
+ delete process.env.HQ_STATE_DIR;
99
+ });
100
+
101
+ it("downloads remote files that don't exist locally", async () => {
102
+ const result = await sync({
103
+ company: "acme",
104
+ vaultConfig: mockConfig,
105
+ hqRoot: tmpDir,
106
+ });
107
+
108
+ expect(result.filesDownloaded).toBe(2);
109
+ expect(result.aborted).toBe(false);
110
+ expect(fs.existsSync(path.join(tmpDir, "docs", "handoff.md"))).toBe(true);
111
+ expect(fs.existsSync(path.join(tmpDir, "knowledge", "readme.md"))).toBe(true);
112
+ });
113
+
114
+ it("throws when no company specified and no active company", async () => {
115
+ await expect(
116
+ sync({ vaultConfig: mockConfig, hqRoot: tmpDir }),
117
+ ).rejects.toThrow(/No company specified/);
118
+ });
119
+
120
+ it("uses active company from .hq/config.json", async () => {
121
+ fs.mkdirSync(path.join(tmpDir, ".hq"), { recursive: true });
122
+ fs.writeFileSync(
123
+ path.join(tmpDir, ".hq", "config.json"),
124
+ JSON.stringify({ activeCompany: "acme" }),
125
+ );
126
+
127
+ const result = await sync({ vaultConfig: mockConfig, hqRoot: tmpDir });
128
+ expect(result.filesDownloaded).toBe(2);
129
+ });
130
+
131
+ it("detects conflicts with local changes and keeps local on --on-conflict keep", async () => {
132
+ fs.mkdirSync(path.join(tmpDir, "docs"), { recursive: true });
133
+ fs.writeFileSync(path.join(tmpDir, "docs", "handoff.md"), "local version");
134
+
135
+ fs.writeFileSync(
136
+ journalPath,
137
+ JSON.stringify({
138
+ version: "1",
139
+ lastSync: new Date().toISOString(),
140
+ files: {
141
+ "docs/handoff.md": {
142
+ hash: "old-hash-from-last-sync",
143
+ size: 20,
144
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
145
+ direction: "down",
146
+ },
147
+ },
148
+ }),
149
+ );
150
+
151
+ const result = await sync({
152
+ company: "acme",
153
+ onConflict: "keep",
154
+ vaultConfig: mockConfig,
155
+ hqRoot: tmpDir,
156
+ });
157
+
158
+ expect(result.conflicts).toBe(1);
159
+ expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
160
+ expect(fs.readFileSync(path.join(tmpDir, "docs", "handoff.md"), "utf-8")).toBe("local version");
161
+ });
162
+
163
+ it("aborts on --on-conflict abort", async () => {
164
+ fs.mkdirSync(path.join(tmpDir, "docs"), { recursive: true });
165
+ fs.writeFileSync(path.join(tmpDir, "docs", "handoff.md"), "local version");
166
+
167
+ fs.writeFileSync(
168
+ journalPath,
169
+ JSON.stringify({
170
+ version: "1",
171
+ lastSync: new Date().toISOString(),
172
+ files: {
173
+ "docs/handoff.md": {
174
+ hash: "old-hash",
175
+ size: 20,
176
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
177
+ direction: "down",
178
+ },
179
+ },
180
+ }),
181
+ );
182
+
183
+ const result = await sync({
184
+ company: "acme",
185
+ onConflict: "abort",
186
+ vaultConfig: mockConfig,
187
+ hqRoot: tmpDir,
188
+ });
189
+
190
+ expect(result.aborted).toBe(true);
191
+ });
192
+
193
+ it("overwrites local on --on-conflict overwrite", async () => {
194
+ fs.mkdirSync(path.join(tmpDir, "docs"), { recursive: true });
195
+ fs.writeFileSync(path.join(tmpDir, "docs", "handoff.md"), "local version");
196
+
197
+ fs.writeFileSync(
198
+ journalPath,
199
+ JSON.stringify({
200
+ version: "1",
201
+ lastSync: new Date().toISOString(),
202
+ files: {
203
+ "docs/handoff.md": {
204
+ hash: "old-hash",
205
+ size: 20,
206
+ syncedAt: new Date(Date.now() - 3600000).toISOString(),
207
+ direction: "down",
208
+ },
209
+ },
210
+ }),
211
+ );
212
+
213
+ const result = await sync({
214
+ company: "acme",
215
+ onConflict: "overwrite",
216
+ vaultConfig: mockConfig,
217
+ hqRoot: tmpDir,
218
+ });
219
+
220
+ expect(result.conflicts).toBe(1);
221
+ expect(result.filesDownloaded).toBeGreaterThanOrEqual(1);
222
+ // File should be overwritten with mock content
223
+ expect(fs.readFileSync(path.join(tmpDir, "docs", "handoff.md"), "utf-8")).toBe("mock file content");
224
+ });
225
+ });
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `hq sync` command — pull everything allowed from entity vault (VLT-5 US-002).
3
+ *
4
+ * Pulls all files the caller's STS session policy permits.
5
+ * Never auto-overwrites local changes — prompts on conflict.
6
+ */
7
+
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ import type { VaultServiceConfig } from "../types.js";
11
+ import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
12
+ import { downloadFile, listRemoteFiles } from "../s3.js";
13
+ import { readJournal, writeJournal, hashFile, updateEntry, getEntry } from "../journal.js";
14
+ import { createIgnoreFilter } from "../ignore.js";
15
+ import { resolveConflict } from "./conflict.js";
16
+ import type { ConflictStrategy } from "./conflict.js";
17
+
18
+ /**
19
+ * Per-file events emitted by `sync()` as it progresses.
20
+ *
21
+ * When `SyncOptions.onEvent` is set, these events are delivered to the caller
22
+ * in place of the default human-readable `console.log` / `console.error`
23
+ * output. This is the seam that lets `hq-sync-runner` stream ndjson to the
24
+ * AppBar menubar without the engine knowing anything about ndjson (ADR-0001).
25
+ *
26
+ * The human CLI (`hq sync`) leaves `onEvent` undefined and falls through to
27
+ * `defaultConsoleLogger` below, which preserves the existing tty output.
28
+ */
29
+ export type SyncProgressEvent =
30
+ | { type: "progress"; path: string; bytes: number; message?: string }
31
+ | { type: "error"; path: string; message: string };
32
+
33
+ export interface SyncOptions {
34
+ /** Company slug or UID (defaults to active company from config) */
35
+ company?: string;
36
+ /** Non-interactive conflict strategy */
37
+ onConflict?: ConflictStrategy;
38
+ /** Vault service config */
39
+ vaultConfig: VaultServiceConfig;
40
+ /** HQ root directory */
41
+ hqRoot: string;
42
+ /**
43
+ * Per-file event callback. When present, suppresses the default
44
+ * `console.log`/`console.error` human output — the caller is expected to
45
+ * render events themselves (e.g. emit ndjson to stdout). When absent, the
46
+ * default human logger is used. See `SyncProgressEvent`.
47
+ */
48
+ onEvent?: (event: SyncProgressEvent) => void;
49
+ }
50
+
51
+ export interface SyncResult {
52
+ filesDownloaded: number;
53
+ bytesDownloaded: number;
54
+ filesSkipped: number;
55
+ conflicts: number;
56
+ aborted: boolean;
57
+ }
58
+
59
+ /**
60
+ * Sync (pull) all allowed files from the entity vault.
61
+ */
62
+ export async function sync(options: SyncOptions): Promise<SyncResult> {
63
+ const { company, onConflict, vaultConfig, hqRoot } = options;
64
+ const emit = options.onEvent ?? defaultConsoleLogger;
65
+
66
+ // Resolve company
67
+ const companyRef = company ?? resolveActiveCompany(hqRoot);
68
+ if (!companyRef) {
69
+ throw new Error(
70
+ "No company specified and no active company found. " +
71
+ "Use --company <slug> or set up .hq/config.json.",
72
+ );
73
+ }
74
+
75
+ // Resolve entity context
76
+ let ctx = await resolveEntityContext(companyRef, vaultConfig);
77
+ const shouldSync = createIgnoreFilter(hqRoot);
78
+ const journal = readJournal(ctx.slug);
79
+
80
+ let filesDownloaded = 0;
81
+ let bytesDownloaded = 0;
82
+ let filesSkipped = 0;
83
+ let conflicts = 0;
84
+
85
+ // List all remote files (IAM session policy filters at the AWS layer)
86
+ const remoteFiles = await listRemoteFiles(ctx);
87
+
88
+ for (const remoteFile of remoteFiles) {
89
+ const localPath = path.join(hqRoot, remoteFile.key);
90
+
91
+ // Apply ignore rules
92
+ if (!shouldSync(localPath)) {
93
+ filesSkipped++;
94
+ continue;
95
+ }
96
+
97
+ // Auto-refresh context if credentials expiring
98
+ if (isExpiringSoon(ctx.expiresAt)) {
99
+ ctx = await refreshEntityContext(companyRef, vaultConfig);
100
+ }
101
+
102
+ // Check for local conflict
103
+ const journalEntry = getEntry(journal, remoteFile.key);
104
+
105
+ if (fs.existsSync(localPath)) {
106
+ const localHash = hashFile(localPath);
107
+
108
+ // If local file has changed since last sync, it's a conflict
109
+ if (journalEntry && journalEntry.hash !== localHash) {
110
+ conflicts++;
111
+
112
+ const resolution = await resolveConflict(
113
+ {
114
+ path: remoteFile.key,
115
+ localHash,
116
+ remoteModified: remoteFile.lastModified,
117
+ localModified: fs.statSync(localPath).mtime,
118
+ direction: "pull",
119
+ },
120
+ onConflict,
121
+ );
122
+
123
+ if (resolution === "abort") {
124
+ writeJournal(ctx.slug, journal);
125
+ return { filesDownloaded, bytesDownloaded, filesSkipped, conflicts, aborted: true };
126
+ }
127
+ if (resolution === "keep" || resolution === "skip") {
128
+ filesSkipped++;
129
+ continue;
130
+ }
131
+ // "overwrite" falls through to download
132
+ } else if (journalEntry && journalEntry.hash === localHash) {
133
+ // Local unchanged since last sync — check if remote changed
134
+ // by comparing etag/timestamp
135
+ const lastSyncTime = new Date(journalEntry.syncedAt).getTime();
136
+ const remoteModTime = remoteFile.lastModified.getTime();
137
+ if (remoteModTime <= lastSyncTime) {
138
+ // Remote hasn't changed either — skip
139
+ filesSkipped++;
140
+ continue;
141
+ }
142
+ }
143
+ }
144
+
145
+ // Download
146
+ try {
147
+ await downloadFile(ctx, remoteFile.key, localPath);
148
+
149
+ const hash = hashFile(localPath);
150
+ const stat = fs.statSync(localPath);
151
+ updateEntry(journal, remoteFile.key, hash, stat.size, "down");
152
+
153
+ // Attach message from journal entry if present
154
+ const remoteJournalMessage = (journalEntry as { message?: string } | undefined)?.message;
155
+ emit({
156
+ type: "progress",
157
+ path: remoteFile.key,
158
+ bytes: stat.size,
159
+ ...(remoteJournalMessage ? { message: remoteJournalMessage } : {}),
160
+ });
161
+
162
+ filesDownloaded++;
163
+ bytesDownloaded += stat.size;
164
+ } catch (err) {
165
+ // STS session policy may deny access to some paths — this is expected
166
+ // for guest members with allowedPrefixes
167
+ if (isAccessDenied(err)) {
168
+ filesSkipped++;
169
+ } else {
170
+ emit({
171
+ type: "error",
172
+ path: remoteFile.key,
173
+ message: err instanceof Error ? err.message : String(err),
174
+ });
175
+ }
176
+ }
177
+ }
178
+
179
+ writeJournal(ctx.slug, journal);
180
+
181
+ return { filesDownloaded, bytesDownloaded, filesSkipped, conflicts, aborted: false };
182
+ }
183
+
184
+ /**
185
+ * Resolve active company from .hq/config.json.
186
+ */
187
+ function resolveActiveCompany(hqRoot: string): string | undefined {
188
+ const configPath = path.join(hqRoot, ".hq", "config.json");
189
+ if (fs.existsSync(configPath)) {
190
+ try {
191
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
192
+ return config.activeCompany ?? config.companySlug;
193
+ } catch {
194
+ // Ignore parse errors
195
+ }
196
+ }
197
+ return undefined;
198
+ }
199
+
200
+ /**
201
+ * Check if an error is an S3 access denied (expected for filtered guests).
202
+ */
203
+ function isAccessDenied(err: unknown): boolean {
204
+ if (err && typeof err === "object" && "name" in err) {
205
+ return err.name === "AccessDenied" || err.name === "Forbidden";
206
+ }
207
+ return false;
208
+ }
209
+
210
+ /**
211
+ * Default human-readable event rendering. Preserves the exact output format
212
+ * that `hq sync` emitted before SyncProgressEvent was introduced, so callers
213
+ * without an `onEvent` see no behavioral change.
214
+ */
215
+ function defaultConsoleLogger(event: SyncProgressEvent): void {
216
+ if (event.type === "progress") {
217
+ if (event.message) {
218
+ console.log(` ✓ ${event.path} — "${event.message}"`);
219
+ } else {
220
+ console.log(` ✓ ${event.path}`);
221
+ }
222
+ } else if (event.type === "error") {
223
+ console.error(` ✗ ${event.path} — ${event.message}`);
224
+ }
225
+ }