@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/cli/share.ts
ADDED
|
@@ -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
|
+
});
|
package/src/cli/sync.ts
ADDED
|
@@ -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
|
+
}
|