@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.
- package/dist/bin/sync-runner.d.ts +111 -0
- package/dist/bin/sync-runner.d.ts.map +1 -0
- package/dist/bin/sync-runner.js +285 -0
- package/dist/bin/sync-runner.js.map +1 -0
- package/dist/bin/sync-runner.test.d.ts +10 -0
- package/dist/bin/sync-runner.test.d.ts.map +1 -0
- package/dist/bin/sync-runner.test.js +492 -0
- package/dist/bin/sync-runner.test.js.map +1 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/share.js +2 -2
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +9 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +33 -10
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +15 -4
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +19 -1
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.d.ts +9 -0
- package/dist/cognito-auth.test.d.ts.map +1 -0
- package/dist/cognito-auth.test.js +113 -0
- package/dist/cognito-auth.test.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +1 -0
- package/dist/context.js.map +1 -1
- package/dist/daemon-worker.d.ts +6 -1
- package/dist/daemon-worker.d.ts.map +1 -1
- package/dist/daemon-worker.js +12 -16
- package/dist/daemon-worker.js.map +1 -1
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +2 -0
- package/dist/daemon.js.map +1 -1
- package/dist/ignore.d.ts +13 -2
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +69 -12
- package/dist/ignore.js.map +1 -1
- package/dist/index.d.ts +24 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -134
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +20 -4
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +45 -8
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.d.ts +9 -0
- package/dist/journal.test.d.ts.map +1 -0
- package/dist/journal.test.js +114 -0
- package/dist/journal.test.js.map +1 -0
- package/dist/s3.d.ts +18 -6
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +57 -56
- package/dist/s3.js.map +1 -1
- package/dist/types.d.ts +34 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts +16 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +19 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +25 -0
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +11 -5
- package/dist/watcher.js.map +1 -1
- package/package.json +15 -3
- package/src/bin/sync-runner.test.ts +617 -0
- package/src/bin/sync-runner.ts +390 -0
- package/src/cli/accept.ts +97 -0
- package/src/cli/conflict.ts +119 -0
- package/src/cli/index.ts +25 -0
- package/src/cli/invite.test.ts +247 -0
- package/src/cli/invite.ts +180 -0
- package/src/cli/promote.ts +123 -0
- package/src/cli/share.test.ts +155 -0
- package/src/cli/share.ts +212 -0
- package/src/cli/sync.test.ts +225 -0
- package/src/cli/sync.ts +225 -0
- package/src/cognito-auth.test.ts +156 -0
- package/src/cognito-auth.ts +18 -1
- package/src/context.test.ts +202 -0
- package/src/context.ts +178 -0
- package/src/daemon-worker.ts +13 -19
- package/src/daemon.ts +2 -0
- package/src/ignore.ts +76 -12
- package/src/index.ts +93 -165
- package/src/journal.test.ts +146 -0
- package/src/journal.ts +53 -11
- package/src/s3.ts +76 -66
- package/src/types.ts +37 -0
- package/src/vault-client.test.ts +390 -0
- package/src/vault-client.ts +400 -0
- package/src/watcher.ts +12 -5
- package/test/invite-flow.integration.test.ts +244 -0
- package/test/share-sync.integration.test.ts +210 -0
package/src/ignore.ts
CHANGED
|
@@ -1,42 +1,106 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Ignore
|
|
3
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
91
|
+
// Layer 1: baseline defaults
|
|
32
92
|
ig.add(DEFAULT_IGNORES);
|
|
33
93
|
|
|
34
|
-
//
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
35
|
+
export {
|
|
36
|
+
createIgnoreFilter,
|
|
37
|
+
isWithinSizeLimit,
|
|
38
|
+
} from "./ignore.js";
|
|
20
39
|
|
|
21
|
-
// Cognito
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
export
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
|
22
|
+
const JOURNAL_FILE_PREFIX = "sync-journal.";
|
|
23
|
+
const JOURNAL_FILE_SUFFIX = ".json";
|
|
11
24
|
|
|
12
|
-
|
|
13
|
-
|
|
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(
|
|
17
|
-
const journalPath = getJournalPath(
|
|
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(
|
|
26
|
-
const journalPath = getJournalPath(
|
|
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
|
}
|