@indigoai-us/hq-cloud 5.1.0 → 5.1.9

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 +134 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -0
  3. package/dist/bin/sync-runner.js +360 -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 +648 -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 +59 -0
  62. package/dist/vault-client.d.ts.map +1 -1
  63. package/dist/vault-client.js +72 -0
  64. package/dist/vault-client.js.map +1 -1
  65. package/dist/vault-client.test.js +160 -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 +804 -0
  73. package/src/bin/sync-runner.ts +499 -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 +94 -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 +563 -0
  97. package/src/vault-client.ts +478 -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,155 @@
1
+ /**
2
+ * Unit tests for hq share 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", () => ({
14
+ uploadFile: vi.fn().mockResolvedValue(undefined),
15
+ downloadFile: vi.fn().mockResolvedValue(undefined),
16
+ listRemoteFiles: vi.fn().mockResolvedValue([]),
17
+ deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
18
+ headRemoteFile: vi.fn().mockResolvedValue(null),
19
+ }));
20
+
21
+ import { share } from "./share.js";
22
+ import { headRemoteFile } from "../s3.js";
23
+
24
+ const mockConfig: VaultServiceConfig = {
25
+ apiUrl: "https://vault-api.test",
26
+ authToken: "test-jwt-token",
27
+ region: "us-east-1",
28
+ };
29
+
30
+ const mockEntity = {
31
+ uid: "cmp_01ABCDEF",
32
+ slug: "acme",
33
+ bucketName: "hq-vault-acme-123",
34
+ status: "active",
35
+ };
36
+
37
+ const mockVendResponse = {
38
+ credentials: {
39
+ accessKeyId: "ASIA_TEST_KEY",
40
+ secretAccessKey: "test-secret",
41
+ sessionToken: "test-session-token",
42
+ expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
43
+ },
44
+ expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
45
+ };
46
+
47
+ function setupFetchMock() {
48
+ const fetchMock = vi.fn().mockImplementation(async (url: string) => {
49
+ const urlStr = String(url);
50
+ if (urlStr.includes("/entity/by-slug/")) {
51
+ return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
52
+ }
53
+ if (urlStr.includes("/sts/vend")) {
54
+ return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
55
+ }
56
+ return { ok: false, status: 404, text: async () => "Not found" };
57
+ });
58
+ vi.stubGlobal("fetch", fetchMock);
59
+ return fetchMock;
60
+ }
61
+
62
+ describe("share", () => {
63
+ let tmpDir: string;
64
+ let stateDir: string;
65
+
66
+ beforeEach(() => {
67
+ clearContextCache();
68
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-share-test-"));
69
+ // Redirect per-company journal into tmp so share() doesn't write to the
70
+ // real ~/.hq during tests (ADR-0001 Phase 5).
71
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-test-"));
72
+ process.env.HQ_STATE_DIR = stateDir;
73
+ setupFetchMock();
74
+ vi.mocked(headRemoteFile).mockResolvedValue(null);
75
+ });
76
+
77
+ afterEach(() => {
78
+ vi.unstubAllGlobals();
79
+ vi.clearAllMocks();
80
+ fs.rmSync(tmpDir, { recursive: true, force: true });
81
+ fs.rmSync(stateDir, { recursive: true, force: true });
82
+ delete process.env.HQ_STATE_DIR;
83
+ });
84
+
85
+ it("shares a single file", async () => {
86
+ const testFile = path.join(tmpDir, "test.md");
87
+ fs.writeFileSync(testFile, "# Hello World");
88
+
89
+ const result = await share({
90
+ paths: [testFile],
91
+ company: "acme",
92
+ vaultConfig: mockConfig,
93
+ hqRoot: tmpDir,
94
+ });
95
+
96
+ expect(result.filesUploaded).toBe(1);
97
+ expect(result.aborted).toBe(false);
98
+ });
99
+
100
+ it("respects ignore rules", async () => {
101
+ fs.mkdirSync(path.join(tmpDir, ".git"));
102
+ fs.writeFileSync(path.join(tmpDir, ".git", "config"), "git config");
103
+ fs.writeFileSync(path.join(tmpDir, "readme.md"), "readme");
104
+
105
+ const result = await share({
106
+ paths: [tmpDir],
107
+ company: "acme",
108
+ vaultConfig: mockConfig,
109
+ hqRoot: tmpDir,
110
+ });
111
+
112
+ expect(result.filesUploaded).toBe(1);
113
+ });
114
+
115
+ it("shares a directory of files", async () => {
116
+ fs.mkdirSync(path.join(tmpDir, "docs"));
117
+ fs.writeFileSync(path.join(tmpDir, "docs", "a.md"), "doc a");
118
+ fs.writeFileSync(path.join(tmpDir, "docs", "b.md"), "doc b");
119
+
120
+ const result = await share({
121
+ paths: [path.join(tmpDir, "docs")],
122
+ company: "acme",
123
+ vaultConfig: mockConfig,
124
+ hqRoot: tmpDir,
125
+ });
126
+
127
+ expect(result.filesUploaded).toBe(2);
128
+ });
129
+
130
+ it("throws when no company specified and no active company", async () => {
131
+ fs.writeFileSync(path.join(tmpDir, "test.md"), "test");
132
+
133
+ await expect(
134
+ share({
135
+ paths: [path.join(tmpDir, "test.md")],
136
+ vaultConfig: mockConfig,
137
+ hqRoot: tmpDir,
138
+ }),
139
+ ).rejects.toThrow(/No company specified/);
140
+ });
141
+
142
+ it("resolves active company from .hq/config.json", async () => {
143
+ fs.mkdirSync(path.join(tmpDir, ".hq"), { recursive: true });
144
+ fs.writeFileSync(path.join(tmpDir, ".hq", "config.json"), JSON.stringify({ activeCompany: "acme" }));
145
+ fs.writeFileSync(path.join(tmpDir, "test.md"), "test");
146
+
147
+ const result = await share({
148
+ paths: [path.join(tmpDir, "test.md")],
149
+ vaultConfig: mockConfig,
150
+ hqRoot: tmpDir,
151
+ });
152
+
153
+ expect(result.filesUploaded).toBe(1);
154
+ });
155
+ });
@@ -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
+ });