@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
package/src/ignore.ts CHANGED
@@ -1,42 +1,106 @@
1
1
  /**
2
- * Ignore file parser for .hqsyncignore
3
- * Uses gitignore-compatible syntax
2
+ * Ignore-file parser for cloud sync.
3
+ *
4
+ * Three layers, evaluated in order (later patterns override earlier ones):
5
+ * 1. Built-in defaults — things that should *never* sync (VCS, node_modules,
6
+ * build artifacts, caches, env files). Cover the common stacks so that a
7
+ * first-time sync over a random project folder doesn't try to push
8
+ * `target/`, `node_modules/`, or `.next/` to S3.
9
+ * 2. Repo `.gitignore` at hqRoot — reuses the user's existing exclusions so
10
+ * we don't re-list every build directory ourselves. Root-level only; we
11
+ * do not recurse like real git.
12
+ * 3. `.hqignore` (preferred) or `.hqsyncignore` (legacy name) at hqRoot —
13
+ * sync-specific overrides. Use `!pattern` to re-include something an
14
+ * earlier layer excluded.
4
15
  */
5
16
 
6
17
  import * as fs from "fs";
7
18
  import * as path from "path";
8
19
  import ignore from "ignore";
9
20
 
10
- // Default patterns that should never sync
21
+ // Patterns that must never sync regardless of project type.
22
+ // Grouped by ecosystem so new stacks are easy to add.
11
23
  const DEFAULT_IGNORES = [
24
+ // VCS + OS
12
25
  ".git/",
13
26
  ".git",
14
- "node_modules/",
15
- "dist/",
16
27
  ".DS_Store",
17
28
  "Thumbs.db",
29
+
30
+ // Node / JS
31
+ "node_modules/",
32
+ "dist/",
33
+ "build/",
34
+ ".next/",
35
+ ".nuxt/",
36
+ ".svelte-kit/",
37
+ ".turbo/",
38
+ ".parcel-cache/",
39
+ ".vite/",
40
+ "coverage/",
41
+
42
+ // Rust / Tauri
43
+ "target/",
44
+
45
+ // Python
46
+ "__pycache__/",
47
+ "*.pyc",
48
+ ".pytest_cache/",
49
+ ".mypy_cache/",
50
+ ".ruff_cache/",
51
+ ".venv/",
52
+ "venv/",
53
+
54
+ // Go / JVM / other
55
+ "vendor/",
56
+ "out/",
57
+ "*.class",
58
+
59
+ // Generic caches / temp
60
+ ".cache/",
61
+ "tmp/",
62
+ ".tmp/",
63
+
64
+ // HQ sync internal state (never round-trip these)
18
65
  "*.pid",
19
66
  ".hq-sync.pid",
20
67
  ".hq-sync-journal.json",
21
68
  ".hq-sync-state.json",
22
69
  "modules.lock",
70
+
71
+ // HQ repos directory (managed separately, not synced)
23
72
  "repos/",
73
+
74
+ // Secrets / env
24
75
  ".env",
25
76
  ".env.*",
26
77
  ];
27
78
 
79
+ function readIgnoreFile(filePath: string): string | null {
80
+ if (!fs.existsSync(filePath)) return null;
81
+ try {
82
+ return fs.readFileSync(filePath, "utf-8");
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
28
88
  export function createIgnoreFilter(hqRoot: string): (filePath: string) => boolean {
29
89
  const ig = ignore();
30
90
 
31
- // Add defaults
91
+ // Layer 1: baseline defaults
32
92
  ig.add(DEFAULT_IGNORES);
33
93
 
34
- // Read .hqsyncignore if it exists
35
- const ignorePath = path.join(hqRoot, ".hqsyncignore");
36
- if (fs.existsSync(ignorePath)) {
37
- const content = fs.readFileSync(ignorePath, "utf-8");
38
- ig.add(content);
39
- }
94
+ // Layer 2: repo's .gitignore (common case — covers most build dirs already)
95
+ const gitignore = readIgnoreFile(path.join(hqRoot, ".gitignore"));
96
+ if (gitignore) ig.add(gitignore);
97
+
98
+ // Layer 3: sync-specific overrides. .hqignore is the documented name;
99
+ // .hqsyncignore is the legacy name we still honor.
100
+ const hqignore =
101
+ readIgnoreFile(path.join(hqRoot, ".hqignore")) ??
102
+ readIgnoreFile(path.join(hqRoot, ".hqsyncignore"));
103
+ if (hqignore) ig.add(hqignore);
40
104
 
41
105
  return (filePath: string): boolean => {
42
106
  const relative = path.relative(hqRoot, filePath);
package/src/index.ts CHANGED
@@ -1,182 +1,110 @@
1
1
  /**
2
2
  * @indigoai-us/hq-cloud — public API
3
- * Used by @indigoai-us/hq-cli to manage cloud sync
3
+ *
4
+ * VLT-5: Entity-aware sync engine. Operations resolve their target bucket
5
+ * and credentials from the vault-service entity registry + STS vending.
4
6
  */
5
7
 
6
- import * as fs from "fs";
7
- import * as path from "path";
8
- import { authenticate, hasCredentials, readCredentials } from "./auth.js";
9
- import {
10
- startDaemon as _startDaemon,
11
- stopDaemon as _stopDaemon,
12
- isDaemonRunning,
13
- } from "./daemon.js";
14
- import { readJournal, writeJournal, hashFile, updateEntry } from "./journal.js";
15
- import { uploadFile, downloadFile, listRemoteFiles } from "./s3.js";
16
- import { createIgnoreFilter, isWithinSizeLimit } from "./ignore.js";
17
- import type { SyncStatus, PushResult, PullResult } from "./types.js";
8
+ export {
9
+ resolveEntityContext,
10
+ refreshEntityContext,
11
+ clearContextCache,
12
+ isExpiringSoon,
13
+ } from "./context.js";
14
+
15
+ export {
16
+ uploadFile,
17
+ downloadFile,
18
+ listRemoteFiles,
19
+ deleteRemoteFile,
20
+ headRemoteFile,
21
+ } from "./s3.js";
22
+
23
+ export type { RemoteFile } from "./s3.js";
24
+
25
+ export {
26
+ readJournal,
27
+ writeJournal,
28
+ hashFile,
29
+ updateEntry,
30
+ getEntry,
31
+ removeEntry,
32
+ getJournalPath,
33
+ } from "./journal.js";
18
34
 
19
- export type { SyncStatus, PushResult, PullResult } from "./types.js";
35
+ export {
36
+ createIgnoreFilter,
37
+ isWithinSizeLimit,
38
+ } from "./ignore.js";
20
39
 
21
- // Cognito identity helpers — used by `hq auth refresh` and any consumer
22
- // that needs a valid HQ access token (deploy skill, onboarding, etc.).
40
+ // Cognito browser-OAuth (VLT-9)
23
41
  export {
24
42
  browserLogin,
25
43
  refreshTokens,
26
- getValidAccessToken,
27
44
  loadCachedTokens,
28
45
  saveCachedTokens,
29
46
  clearCachedTokens,
30
47
  isExpiring,
48
+ getValidAccessToken,
31
49
  CognitoAuthError,
32
50
  } from "./cognito-auth.js";
33
51
  export type { CognitoAuthConfig, CognitoTokens } from "./cognito-auth.js";
34
52
 
35
- /**
36
- * Initialize cloud sync — authenticate and provision bucket
37
- */
38
- export async function initSync(hqRoot: string): Promise<void> {
39
- if (hasCredentials()) {
40
- console.log(" Already authenticated. Use 'hq sync start' to begin syncing.");
41
- return;
42
- }
43
-
44
- console.log(" Setting up IndigoAI cloud sync...");
45
- const creds = await authenticate();
46
- console.log(` ✓ Authenticated as ${creds.userId}`);
47
- console.log(` ✓ Bucket: ${creds.bucket}`);
48
- console.log(` ✓ Region: ${creds.region}`);
49
- console.log();
50
- console.log(" Run 'hq sync start' to begin syncing.");
51
- }
52
-
53
- /**
54
- * Start the background sync daemon
55
- */
56
- export async function startDaemon(hqRoot: string): Promise<void> {
57
- if (!hasCredentials()) {
58
- throw new Error("Not authenticated. Run 'hq sync init' first.");
59
- }
60
- _startDaemon(hqRoot);
61
- }
62
-
63
- /**
64
- * Stop the background sync daemon
65
- */
66
- export async function stopDaemon(hqRoot: string): Promise<void> {
67
- _stopDaemon(hqRoot);
68
- }
69
-
70
- /**
71
- * Get current sync status
72
- */
73
- export async function getStatus(hqRoot: string): Promise<SyncStatus> {
74
- const journal = readJournal(hqRoot);
75
- const creds = readCredentials();
76
- const running = isDaemonRunning(hqRoot);
77
- const errors: string[] = [];
78
-
79
- if (!creds) {
80
- errors.push("Not authenticated — run 'hq sync init'");
81
- }
82
-
83
- return {
84
- running,
85
- lastSync: journal.lastSync || null,
86
- fileCount: Object.keys(journal.files).length,
87
- bucket: creds?.bucket || null,
88
- errors,
89
- };
90
- }
91
-
92
- /**
93
- * Force push all local files to S3
94
- */
95
- export async function pushAll(hqRoot: string): Promise<PushResult> {
96
- const shouldSync = createIgnoreFilter(hqRoot);
97
- const journal = readJournal(hqRoot);
98
- let filesUploaded = 0;
99
- let bytesUploaded = 0;
100
-
101
- const files = walkDir(hqRoot, hqRoot, shouldSync);
102
-
103
- for (const { absolutePath, relativePath } of files) {
104
- if (!isWithinSizeLimit(absolutePath)) continue;
105
-
106
- try {
107
- const hash = hashFile(absolutePath);
108
- const stat = fs.statSync(absolutePath);
109
-
110
- await uploadFile(absolutePath, relativePath);
111
- updateEntry(journal, relativePath, hash, stat.size, "up");
112
- filesUploaded++;
113
- bytesUploaded += stat.size;
114
- } catch (err) {
115
- console.error(
116
- ` Failed: ${relativePath} — ${err instanceof Error ? err.message : err}`
117
- );
118
- }
119
- }
120
-
121
- writeJournal(hqRoot, journal);
122
- return { filesUploaded, bytesUploaded };
123
- }
124
-
125
- /**
126
- * Force pull all remote files to local
127
- */
128
- export async function pullAll(hqRoot: string): Promise<PullResult> {
129
- const journal = readJournal(hqRoot);
130
- let filesDownloaded = 0;
131
- let bytesDownloaded = 0;
132
-
133
- const remoteFiles = await listRemoteFiles();
134
-
135
- for (const file of remoteFiles) {
136
- try {
137
- const localPath = path.join(hqRoot, file.relativePath);
138
- await downloadFile(file.relativePath, localPath);
139
-
140
- const hash = hashFile(localPath);
141
- updateEntry(journal, file.relativePath, hash, file.size, "down");
142
- filesDownloaded++;
143
- bytesDownloaded += file.size;
144
- } catch (err) {
145
- console.error(
146
- ` Failed: ${file.relativePath} — ${err instanceof Error ? err.message : err}`
147
- );
148
- }
149
- }
150
-
151
- writeJournal(hqRoot, journal);
152
- return { filesDownloaded, bytesDownloaded };
153
- }
154
-
155
- // Helper: recursively walk a directory
156
- function walkDir(
157
- dir: string,
158
- root: string,
159
- filter: (p: string) => boolean
160
- ): { absolutePath: string; relativePath: string }[] {
161
- const results: { absolutePath: string; relativePath: string }[] = [];
162
-
163
- if (!fs.existsSync(dir)) return results;
164
-
165
- const entries = fs.readdirSync(dir, { withFileTypes: true });
166
- for (const entry of entries) {
167
- const absolutePath = path.join(dir, entry.name);
168
-
169
- if (!filter(absolutePath)) continue;
170
-
171
- if (entry.isDirectory()) {
172
- results.push(...walkDir(absolutePath, root, filter));
173
- } else if (entry.isFile()) {
174
- results.push({
175
- absolutePath,
176
- relativePath: path.relative(root, absolutePath),
177
- });
178
- }
179
- }
180
-
181
- return results;
182
- }
53
+ // VaultClient SDK (VLT-7)
54
+ export { VaultClient } from "./vault-client.js";
55
+ export {
56
+ VaultClientError,
57
+ VaultAuthError,
58
+ VaultPermissionDeniedError,
59
+ VaultNotFoundError,
60
+ VaultConflictError,
61
+ } from "./vault-client.js";
62
+ export type {
63
+ MembershipRole,
64
+ MembershipStatus,
65
+ Membership,
66
+ CreateInviteInput,
67
+ CreateInviteResult,
68
+ AcceptInviteResult,
69
+ UpdateRoleInput,
70
+ EntityInfo,
71
+ CreateEntityInput,
72
+ CreateEntityResult,
73
+ } from "./vault-client.js";
74
+
75
+ // STS child vending (VLT-8)
76
+ export type {
77
+ TaskAction,
78
+ TaskScope,
79
+ VendChildInput,
80
+ VendChildResult,
81
+ StsChildCredentials,
82
+ } from "./vault-client.js";
83
+
84
+ // CLI commands
85
+ export { share, sync } from "./cli/index.js";
86
+ export type { ShareOptions, ShareResult, SyncOptions, SyncResult, SyncProgressEvent } from "./cli/index.js";
87
+ export { resolveConflict, showDiff } from "./cli/index.js";
88
+ export type { ConflictStrategy, ConflictInfo, ConflictResolution } from "./cli/index.js";
89
+
90
+ // Membership CLI commands (VLT-7)
91
+ export { invite, listInvites, revokeInvite } from "./cli/index.js";
92
+ export type { InviteOptions, InviteResult, InviteListOptions, InviteRevokeOptions } from "./cli/index.js";
93
+ export { accept, parseToken } from "./cli/index.js";
94
+ export type { AcceptOptions, AcceptResult } from "./cli/index.js";
95
+ export { promote } from "./cli/index.js";
96
+ export type { PromoteOptions, PromoteResult } from "./cli/index.js";
97
+
98
+ export type {
99
+ EntityContext,
100
+ VaultCredentials,
101
+ VaultServiceConfig,
102
+ SyncConfig,
103
+ Credentials,
104
+ JournalEntry,
105
+ SyncJournal,
106
+ SyncStatus,
107
+ PushResult,
108
+ PullResult,
109
+ DaemonState,
110
+ } from "./types.js";
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Unit tests for the sync journal (ADR-0001 Phase 5).
3
+ *
4
+ * Verifies per-company isolation, HQ_STATE_DIR override, and filename
5
+ * sanitization — all behaviors that the pre-Phase-5 monolithic journal
6
+ * didn't need.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
10
+ import * as fs from "fs";
11
+ import * as os from "os";
12
+ import * as path from "path";
13
+ import {
14
+ getJournalPath,
15
+ getStateDir,
16
+ readJournal,
17
+ writeJournal,
18
+ updateEntry,
19
+ } from "./journal.js";
20
+ import type { SyncJournal } from "./types.js";
21
+
22
+ describe("journal", () => {
23
+ let stateDir: string;
24
+
25
+ beforeEach(() => {
26
+ stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-journal-test-"));
27
+ process.env.HQ_STATE_DIR = stateDir;
28
+ });
29
+
30
+ afterEach(() => {
31
+ fs.rmSync(stateDir, { recursive: true, force: true });
32
+ delete process.env.HQ_STATE_DIR;
33
+ });
34
+
35
+ describe("getStateDir", () => {
36
+ it("honors HQ_STATE_DIR env var", () => {
37
+ expect(getStateDir()).toBe(stateDir);
38
+ });
39
+
40
+ it("falls back to ~/.hq when env var unset", () => {
41
+ delete process.env.HQ_STATE_DIR;
42
+ expect(getStateDir()).toBe(path.join(os.homedir(), ".hq"));
43
+ });
44
+ });
45
+
46
+ describe("getJournalPath", () => {
47
+ it("produces a per-slug filename", () => {
48
+ expect(getJournalPath("indigo")).toBe(
49
+ path.join(stateDir, "sync-journal.indigo.json"),
50
+ );
51
+ });
52
+
53
+ it("isolates different slugs into different files", () => {
54
+ expect(getJournalPath("indigo")).not.toBe(getJournalPath("brandstage"));
55
+ });
56
+
57
+ it("sanitizes path-unsafe characters", () => {
58
+ expect(getJournalPath("foo/bar")).toBe(
59
+ path.join(stateDir, "sync-journal.foo_bar.json"),
60
+ );
61
+ expect(getJournalPath("../escape")).toBe(
62
+ path.join(stateDir, "sync-journal.___escape.json"),
63
+ );
64
+ });
65
+
66
+ it("throws on empty slug", () => {
67
+ expect(() => getJournalPath("")).toThrow(/slug is required/);
68
+ });
69
+
70
+ it("throws on slug that sanitizes to empty", () => {
71
+ expect(() => getJournalPath("///")).toThrow(/empty identifier/);
72
+ });
73
+ });
74
+
75
+ describe("readJournal", () => {
76
+ it("returns an empty journal when the file doesn't exist", () => {
77
+ const j = readJournal("indigo");
78
+ expect(j.version).toBe("1");
79
+ expect(j.files).toEqual({});
80
+ expect(j.lastSync).toBe("");
81
+ });
82
+
83
+ it("reads a journal written with writeJournal", () => {
84
+ const original: SyncJournal = {
85
+ version: "1",
86
+ lastSync: "2026-04-19T00:00:00.000Z",
87
+ files: {
88
+ "docs/handoff.md": {
89
+ hash: "abc123",
90
+ size: 42,
91
+ syncedAt: "2026-04-19T00:00:00.000Z",
92
+ direction: "down",
93
+ },
94
+ },
95
+ };
96
+ writeJournal("indigo", original);
97
+ const roundTripped = readJournal("indigo");
98
+ expect(roundTripped).toEqual(original);
99
+ });
100
+ });
101
+
102
+ describe("writeJournal", () => {
103
+ it("creates the state directory if it doesn't exist", () => {
104
+ const nestedDir = path.join(stateDir, "nested", "deep");
105
+ process.env.HQ_STATE_DIR = nestedDir;
106
+ expect(fs.existsSync(nestedDir)).toBe(false);
107
+
108
+ writeJournal("indigo", { version: "1", lastSync: "", files: {} });
109
+ expect(fs.existsSync(nestedDir)).toBe(true);
110
+ expect(
111
+ fs.existsSync(path.join(nestedDir, "sync-journal.indigo.json")),
112
+ ).toBe(true);
113
+ });
114
+
115
+ it("keeps per-company journals independent", () => {
116
+ writeJournal("indigo", {
117
+ version: "1",
118
+ lastSync: "",
119
+ files: { "a.md": { hash: "1", size: 1, syncedAt: "", direction: "up" } },
120
+ });
121
+ writeJournal("brandstage", {
122
+ version: "1",
123
+ lastSync: "",
124
+ files: { "b.md": { hash: "2", size: 2, syncedAt: "", direction: "up" } },
125
+ });
126
+
127
+ const indigo = readJournal("indigo");
128
+ const brandstage = readJournal("brandstage");
129
+ expect(indigo.files).toHaveProperty("a.md");
130
+ expect(indigo.files).not.toHaveProperty("b.md");
131
+ expect(brandstage.files).toHaveProperty("b.md");
132
+ expect(brandstage.files).not.toHaveProperty("a.md");
133
+ });
134
+ });
135
+
136
+ describe("updateEntry", () => {
137
+ it("stamps lastSync and the per-file syncedAt", () => {
138
+ const j: SyncJournal = { version: "1", lastSync: "", files: {} };
139
+ updateEntry(j, "foo.md", "hash", 10, "up");
140
+ expect(j.files["foo.md"]?.hash).toBe("hash");
141
+ expect(j.files["foo.md"]?.direction).toBe("up");
142
+ expect(j.lastSync).not.toBe("");
143
+ expect(j.files["foo.md"]?.syncedAt).not.toBe("");
144
+ });
145
+ });
146
+ });
package/src/journal.ts CHANGED
@@ -1,20 +1,61 @@
1
1
  /**
2
- * Sync journal — tracks file state for conflict detection
2
+ * Sync journal — tracks per-file state (hash, size, last-synced direction) so
3
+ * sync/share can detect local edits that would be clobbered by a blind pull.
4
+ *
5
+ * ADR-0001 Phase 5: the journal is sharded by company slug and lives in
6
+ * `~/.hq/`, not inside the HQ content root. One monolithic journal per HQ
7
+ * install conflates state across companies and forces every runner to
8
+ * serialize through the same file — splitting it lets `hq-sync-runner
9
+ * --companies` fan out without contention, and a corrupted shard only affects
10
+ * one company.
11
+ *
12
+ * Path: `{stateDir}/sync-journal.{slug}.json`, where `stateDir` resolves to
13
+ * `HQ_STATE_DIR` (if set) or `~/.hq`.
3
14
  */
4
15
 
5
16
  import * as fs from "fs";
17
+ import * as os from "os";
6
18
  import * as path from "path";
7
19
  import * as crypto from "crypto";
8
20
  import type { SyncJournal, JournalEntry } from "./types.js";
9
21
 
10
- const JOURNAL_FILE = ".hq-sync-journal.json";
22
+ const JOURNAL_FILE_PREFIX = "sync-journal.";
23
+ const JOURNAL_FILE_SUFFIX = ".json";
11
24
 
12
- export function getJournalPath(hqRoot: string): string {
13
- return path.join(hqRoot, JOURNAL_FILE);
25
+ /**
26
+ * Where per-company journals are stored. Honors `HQ_STATE_DIR` for tests and
27
+ * non-standard installs; otherwise falls back to `~/.hq`.
28
+ */
29
+ export function getStateDir(): string {
30
+ return process.env.HQ_STATE_DIR ?? path.join(os.homedir(), ".hq");
31
+ }
32
+
33
+ /**
34
+ * Filename-safe form of a slug. Slugs from vault-service are already
35
+ * URL-safe, but this guards against paths, dots, or anything the filesystem
36
+ * might interpret. Empty-or-invalid slugs throw rather than silently writing
37
+ * to a shared "sync-journal..json" file.
38
+ */
39
+ function sanitizeSlug(slug: string): string {
40
+ if (!slug) {
41
+ throw new Error("journal: slug is required (empty or undefined)");
42
+ }
43
+ const cleaned = slug.replace(/[^a-zA-Z0-9_-]/g, "_");
44
+ if (!cleaned || /^[_-]+$/.test(cleaned)) {
45
+ throw new Error(`journal: slug "${slug}" sanitizes to an empty identifier`);
46
+ }
47
+ return cleaned;
48
+ }
49
+
50
+ export function getJournalPath(slug: string): string {
51
+ return path.join(
52
+ getStateDir(),
53
+ `${JOURNAL_FILE_PREFIX}${sanitizeSlug(slug)}${JOURNAL_FILE_SUFFIX}`,
54
+ );
14
55
  }
15
56
 
16
- export function readJournal(hqRoot: string): SyncJournal {
17
- const journalPath = getJournalPath(hqRoot);
57
+ export function readJournal(slug: string): SyncJournal {
58
+ const journalPath = getJournalPath(slug);
18
59
  if (fs.existsSync(journalPath)) {
19
60
  const content = fs.readFileSync(journalPath, "utf-8");
20
61
  return JSON.parse(content) as SyncJournal;
@@ -22,8 +63,9 @@ export function readJournal(hqRoot: string): SyncJournal {
22
63
  return { version: "1", lastSync: "", files: {} };
23
64
  }
24
65
 
25
- export function writeJournal(hqRoot: string, journal: SyncJournal): void {
26
- const journalPath = getJournalPath(hqRoot);
66
+ export function writeJournal(slug: string, journal: SyncJournal): void {
67
+ const journalPath = getJournalPath(slug);
68
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
27
69
  fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2));
28
70
  }
29
71
 
@@ -37,7 +79,7 @@ export function updateEntry(
37
79
  relativePath: string,
38
80
  hash: string,
39
81
  size: number,
40
- direction: "up" | "down"
82
+ direction: "up" | "down",
41
83
  ): void {
42
84
  journal.files[relativePath] = {
43
85
  hash,
@@ -50,14 +92,14 @@ export function updateEntry(
50
92
 
51
93
  export function getEntry(
52
94
  journal: SyncJournal,
53
- relativePath: string
95
+ relativePath: string,
54
96
  ): JournalEntry | undefined {
55
97
  return journal.files[relativePath];
56
98
  }
57
99
 
58
100
  export function removeEntry(
59
101
  journal: SyncJournal,
60
- relativePath: string
102
+ relativePath: string,
61
103
  ): void {
62
104
  delete journal.files[relativePath];
63
105
  }